diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e3f2150c8..ef4ddeeb59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,33 @@ # Changelog -## [3.3.0-nightly.7](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.3.1-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.2.0...HEAD) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.3.0...HEAD) + +**πŸ› 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) **πŸš€ Enhancements** +- Python console interpreter [\#1940](https://github.com/pypeclub/OpenPype/pull/1940) +- Global: Updated logos and Default settings [\#1927](https://github.com/pypeclub/OpenPype/pull/1927) +- 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) +- 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) +- submodules: avalon-core update [\#1911](https://github.com/pypeclub/OpenPype/pull/1911) +- 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) - 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) - Expose stop timer through rest api. [\#1886](https://github.com/pypeclub/OpenPype/pull/1886) @@ -15,41 +37,39 @@ - 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) -- Anatomy schema validation [\#1864](https://github.com/pypeclub/OpenPype/pull/1864) -- Ftrack prepare project structure [\#1861](https://github.com/pypeclub/OpenPype/pull/1861) -- Independent general environments [\#1853](https://github.com/pypeclub/OpenPype/pull/1853) -- TVPaint Start Frame [\#1844](https://github.com/pypeclub/OpenPype/pull/1844) -- Ftrack push attributes action adds traceback to job [\#1843](https://github.com/pypeclub/OpenPype/pull/1843) -- Prepare project action enhance [\#1838](https://github.com/pypeclub/OpenPype/pull/1838) -- Standalone Publish of textures family [\#1834](https://github.com/pypeclub/OpenPype/pull/1834) -- nuke: settings create missing default subsets [\#1829](https://github.com/pypeclub/OpenPype/pull/1829) -- Update poetry lock [\#1823](https://github.com/pypeclub/OpenPype/pull/1823) -- Settings: settings for plugins [\#1819](https://github.com/pypeclub/OpenPype/pull/1819) -- Maya: Deadline custom settings [\#1797](https://github.com/pypeclub/OpenPype/pull/1797) -- Maya: Shader name validation [\#1762](https://github.com/pypeclub/OpenPype/pull/1762) +- Maya: support for configurable `dirmap` πŸ—ΊοΈ [\#1859](https://github.com/pypeclub/OpenPype/pull/1859) +- Settings list can use template or schema as object type [\#1815](https://github.com/pypeclub/OpenPype/pull/1815) **πŸ› Bug fixes** +- Fix - ftrack family was added incorrectly in some cases [\#1935](https://github.com/pypeclub/OpenPype/pull/1935) +- 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) +- Nuke: submit to farm failed due `ftrack` family remove [\#1926](https://github.com/pypeclub/OpenPype/pull/1926) +- Fix - validate takes repre\["files"\] as list all the time [\#1922](https://github.com/pypeclub/OpenPype/pull/1922) +- 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) +- Fix - validators for textures workfiles trigger only for textures workfiles [\#1913](https://github.com/pypeclub/OpenPype/pull/1913) +- Settings UI: List order works as expected [\#1906](https://github.com/pypeclub/OpenPype/pull/1906) +- 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) - Bug: fixed python detection [\#1893](https://github.com/pypeclub/OpenPype/pull/1893) - 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) - Normalize path returned from Workfiles. [\#1880](https://github.com/pypeclub/OpenPype/pull/1880) - Workfiles tool event arguments fix [\#1862](https://github.com/pypeclub/OpenPype/pull/1862) -- imageio: fix grouping [\#1856](https://github.com/pypeclub/OpenPype/pull/1856) -- publisher: missing version in subset prop [\#1849](https://github.com/pypeclub/OpenPype/pull/1849) -- Ftrack type error fix in sync to avalon event handler [\#1845](https://github.com/pypeclub/OpenPype/pull/1845) -- Nuke: updating effects subset fail [\#1841](https://github.com/pypeclub/OpenPype/pull/1841) -- nuke: write render node skipped with crop [\#1836](https://github.com/pypeclub/OpenPype/pull/1836) -- Project folder structure overrides [\#1813](https://github.com/pypeclub/OpenPype/pull/1813) -- Maya: fix yeti settings path in extractor [\#1809](https://github.com/pypeclub/OpenPype/pull/1809) -- Failsafe for cross project containers. [\#1806](https://github.com/pypeclub/OpenPype/pull/1806) -- Houdini colector formatting keys fix [\#1802](https://github.com/pypeclub/OpenPype/pull/1802) +- Maya: don't add reference members as connections to the container set πŸ“¦ [\#1855](https://github.com/pypeclub/OpenPype/pull/1855) +- Settings error dialog on show [\#1798](https://github.com/pypeclub/OpenPype/pull/1798) **Merged pull requests:** +- Fix - make AE workfile publish to Ftrack configurable [\#1937](https://github.com/pypeclub/OpenPype/pull/1937) +- Settings UI: Breadcrumbs in settings [\#1932](https://github.com/pypeclub/OpenPype/pull/1932) +- Add support for multiple Deadline β˜ οΈβž– servers [\#1905](https://github.com/pypeclub/OpenPype/pull/1905) - Maya: add support for `RedshiftNormalMap` node, fix `tx` linear space πŸš€ [\#1863](https://github.com/pypeclub/OpenPype/pull/1863) -- Add support for pyenv-win on windows [\#1822](https://github.com/pypeclub/OpenPype/pull/1822) -- PS, AE - send actual context when another webserver is running [\#1811](https://github.com/pypeclub/OpenPype/pull/1811) +- Maya: expected files -\> render products βš™οΈ overhaul [\#1812](https://github.com/pypeclub/OpenPype/pull/1812) ## [3.2.0](https://github.com/pypeclub/OpenPype/tree/3.2.0) (2021-07-13) @@ -60,51 +80,20 @@ - Nuke: ftrack family plugin settings preset [\#1805](https://github.com/pypeclub/OpenPype/pull/1805) - Standalone publisher last project [\#1799](https://github.com/pypeclub/OpenPype/pull/1799) - Ftrack Multiple notes as server action [\#1795](https://github.com/pypeclub/OpenPype/pull/1795) -- Settings conditional dict [\#1777](https://github.com/pypeclub/OpenPype/pull/1777) -- Settings application use python 2 only where needed [\#1776](https://github.com/pypeclub/OpenPype/pull/1776) -- Settings UI copy/paste [\#1769](https://github.com/pypeclub/OpenPype/pull/1769) -- Workfile tool widths [\#1766](https://github.com/pypeclub/OpenPype/pull/1766) -- Push hierarchical attributes care about task parent changes [\#1763](https://github.com/pypeclub/OpenPype/pull/1763) -- Application executables with environment variables [\#1757](https://github.com/pypeclub/OpenPype/pull/1757) -- Deadline: Nuke submission additional attributes [\#1756](https://github.com/pypeclub/OpenPype/pull/1756) -- Settings schema without prefill [\#1753](https://github.com/pypeclub/OpenPype/pull/1753) -- Settings Hosts enum [\#1739](https://github.com/pypeclub/OpenPype/pull/1739) **πŸ› Bug fixes** - nuke: fixing wrong name of family folder when `used existing frames` [\#1803](https://github.com/pypeclub/OpenPype/pull/1803) - Collect ftrack family bugs [\#1801](https://github.com/pypeclub/OpenPype/pull/1801) -- Invitee email can be None which break the Ftrack commit. [\#1788](https://github.com/pypeclub/OpenPype/pull/1788) -- Fix: staging and `--use-version` option [\#1786](https://github.com/pypeclub/OpenPype/pull/1786) -- FFprobe streams order [\#1775](https://github.com/pypeclub/OpenPype/pull/1775) -- Fix - single file files are str only, cast it to list to count properly [\#1772](https://github.com/pypeclub/OpenPype/pull/1772) -- Environments in app executable for MacOS [\#1768](https://github.com/pypeclub/OpenPype/pull/1768) -- Project specific environments [\#1767](https://github.com/pypeclub/OpenPype/pull/1767) -- Settings UI with refresh button [\#1764](https://github.com/pypeclub/OpenPype/pull/1764) -- Standalone publisher thumbnail extractor fix [\#1761](https://github.com/pypeclub/OpenPype/pull/1761) -- Anatomy others templates don't cause crash [\#1758](https://github.com/pypeclub/OpenPype/pull/1758) -- Backend acre module commit update [\#1745](https://github.com/pypeclub/OpenPype/pull/1745) -- hiero: precollect instances failing when audio selected [\#1743](https://github.com/pypeclub/OpenPype/pull/1743) -- Hiero: creator instance error [\#1742](https://github.com/pypeclub/OpenPype/pull/1742) -- Nuke: fixing render creator for no selection format failing [\#1741](https://github.com/pypeclub/OpenPype/pull/1741) -- StandalonePublisher: failing collector for editorial [\#1738](https://github.com/pypeclub/OpenPype/pull/1738) **Merged pull requests:** - Build: don't add Poetry to `PATH` [\#1808](https://github.com/pypeclub/OpenPype/pull/1808) -- Bump prismjs from 1.23.0 to 1.24.0 in /website [\#1773](https://github.com/pypeclub/OpenPype/pull/1773) -- Bc/fix/docs [\#1771](https://github.com/pypeclub/OpenPype/pull/1771) -- TVPaint ftrack family [\#1755](https://github.com/pypeclub/OpenPype/pull/1755) ## [2.18.4](https://github.com/pypeclub/OpenPype/tree/2.18.4) (2021-06-24) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.3...2.18.4) -**Merged pull requests:** - -- celaction fixes [\#1754](https://github.com/pypeclub/OpenPype/pull/1754) -- celaciton: audio subset changed data structure [\#1750](https://github.com/pypeclub/OpenPype/pull/1750) - ## [2.18.3](https://github.com/pypeclub/OpenPype/tree/2.18.3) (2021-06-23) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.2.0-nightly.2...2.18.3) diff --git a/README.md b/README.md index 6b4495c9b6..209af24c75 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ The main things you will need to run and build OpenPype are: - PowerShell 5.0+ (Windows) - Bash (Linux) - [**Python 3.7.8**](#python) or higher -- [**MongoDB**](#database) +- [**MongoDB**](#database) (needed only for local development) It can be built and ran on all common platforms. We develop and test on the following: @@ -126,6 +126,16 @@ pyenv local 3.7.9 ### Linux +#### Docker +Easiest way to build OpenPype on Linux is using [Docker](https://www.docker.com/). Just run: + +```sh +sudo ./tools/docker_build.sh +``` + +If all is successful, you'll find built OpenPype in `./build/` folder. + +#### Manual build You will need [Python 3.7](https://www.python.org/downloads/) and [git](https://git-scm.com/downloads). You'll also need [curl](https://curl.se) on systems that doesn't have one preinstalled. To build Python related stuff, you need Python header files installed (`python3-dev` on Ubuntu for example). @@ -133,7 +143,6 @@ To build Python related stuff, you need Python header files installed (`python3- You'll need also other tools to build some OpenPype dependencies like [CMake](https://cmake.org/). Python 3 should be part of all modern distributions. You can use your package manager to install **git** and **cmake**. -
Details for Ubuntu Install git, cmake and curl diff --git a/openpype/hosts/aftereffects/plugins/create/create_local_render.py b/openpype/hosts/aftereffects/plugins/create/create_local_render.py new file mode 100644 index 0000000000..9cc06eb698 --- /dev/null +++ b/openpype/hosts/aftereffects/plugins/create/create_local_render.py @@ -0,0 +1,17 @@ +from openpype.hosts.aftereffects.plugins.create import create_render + +import logging + +log = logging.getLogger(__name__) + + +class CreateLocalRender(create_render.CreateRender): + """ Creator to render locally. + + Created only after default render on farm. So family 'render.local' is + used for backward compatibility. + """ + + name = "renderDefault" + label = "Render Locally" + family = "renderLocal" diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index baac64ed0c..be024b7e24 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -1,10 +1,14 @@ -from openpype.lib import abstract_collect_render -from openpype.lib.abstract_collect_render import RenderInstance -import pyblish.api -import attr import os +import re +import attr +import tempfile from avalon import aftereffects +import pyblish.api + +from openpype.settings import get_project_settings +from openpype.lib import abstract_collect_render +from openpype.lib.abstract_collect_render import RenderInstance @attr.s @@ -13,6 +17,8 @@ class AERenderInstance(RenderInstance): comp_name = attr.ib(default=None) comp_id = attr.ib(default=None) fps = attr.ib(default=None) + projectEntity = attr.ib(default=None) + stagingDir = attr.ib(default=None) class CollectAERender(abstract_collect_render.AbstractCollectRender): @@ -21,6 +27,11 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): label = "Collect After Effects Render Layers" hosts = ["aftereffects"] + # internal + family_remapping = { + "render": ("render.farm", "farm"), # (family, label) + "renderLocal": ("render", "local") + } padding_width = 6 rendered_extension = 'png' @@ -62,14 +73,16 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): fps = work_area_info.frameRate # TODO add resolution when supported by extension - if inst["family"] == "render" and inst["active"]: + if inst["family"] in self.family_remapping.keys() \ + and inst["active"]: + remapped_family = self.family_remapping[inst["family"]] instance = AERenderInstance( - family="render.farm", # other way integrate would catch it - families=["render.farm"], + family=remapped_family[0], + families=[remapped_family[0]], version=version, time="", source=current_file, - label="{} - farm".format(inst["subset"]), + label="{} - {}".format(inst["subset"], remapped_family[1]), subset=inst["subset"], asset=context.data["assetEntity"]["name"], attachTo=False, @@ -105,6 +118,30 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): instance.outputDir = self._get_output_dir(instance) + settings = get_project_settings(os.getenv("AVALON_PROJECT")) + reviewable_subset_filter = \ + (settings["deadline"] + ["publish"] + ["ProcessSubmittedJobOnFarm"] + ["aov_filter"]) + + if inst["family"] == "renderLocal": + # for local renders + instance.anatomyData["version"] = instance.version + instance.anatomyData["subset"] = instance.subset + instance.stagingDir = tempfile.mkdtemp() + instance.projectEntity = project_entity + + if self.hosts[0] in reviewable_subset_filter.keys(): + for aov_pattern in \ + reviewable_subset_filter[self.hosts[0]]: + if re.match(aov_pattern, instance.subset): + instance.families.append("review") + instance.review = True + break + + self.log.info("New instance:: {}".format(instance)) + instances.append(instance) return instances diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py index 2f8f9ae91b..c1c2be4855 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py @@ -47,7 +47,7 @@ class CollectWorkfile(pyblish.api.ContextPlugin): "subset": subset, "label": scene_file, "family": family, - "families": [family, "ftrack"], + "families": [family], "representations": list() }) diff --git a/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py b/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py new file mode 100644 index 0000000000..37337e7fee --- /dev/null +++ b/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py @@ -0,0 +1,82 @@ +import os +import six +import sys + +import openpype.api +from avalon import aftereffects + + +class ExtractLocalRender(openpype.api.Extractor): + """Render RenderQueue locally.""" + + order = openpype.api.Extractor.order - 0.47 + label = "Extract Local Render" + hosts = ["aftereffects"] + families = ["render"] + + def process(self, instance): + stub = aftereffects.stub() + staging_dir = instance.data["stagingDir"] + self.log.info("staging_dir::{}".format(staging_dir)) + + stub.render(staging_dir) + + # pull file name from Render Queue Output module + render_q = stub.get_render_info() + if not render_q: + raise ValueError("No file extension set in Render Queue") + _, ext = os.path.splitext(os.path.basename(render_q.file_name)) + ext = ext[1:] + + first_file_path = None + files = [] + self.log.info("files::{}".format(os.listdir(staging_dir))) + for file_name in os.listdir(staging_dir): + files.append(file_name) + if first_file_path is None: + first_file_path = os.path.join(staging_dir, + file_name) + + resulting_files = files + if len(files) == 1: + resulting_files = files[0] + + repre_data = { + "frameStart": instance.data["frameStart"], + "frameEnd": instance.data["frameEnd"], + "name": ext, + "ext": ext, + "files": resulting_files, + "stagingDir": staging_dir + } + if instance.data["review"]: + repre_data["tags"] = ["review"] + + instance.data["representations"] = [repre_data] + + ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") + # Generate thumbnail. + thumbnail_path = os.path.join(staging_dir, + "thumbnail.jpg") + + args = [ + ffmpeg_path, "-y", + "-i", first_file_path, + "-vf", "scale=300:-1", + "-vframes", "1", + thumbnail_path + ] + self.log.debug("Thumbnail args:: {}".format(args)) + try: + output = openpype.lib.run_subprocess(args) + except TypeError: + self.log.warning("Error in creating thumbnail") + six.reraise(*sys.exc_info()) + + instance.data["representations"].append({ + "name": "thumbnail", + "ext": "jpg", + "files": os.path.basename(thumbnail_path), + "stagingDir": staging_dir, + "tags": ["thumbnail"] + }) diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py index 5301a2f3ea..7fba11957c 100644 --- a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py @@ -53,7 +53,7 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder label = "Validate Scene Settings" - families = ["render.farm"] + families = ["render.farm", "render"] hosts = ["aftereffects"] optional = True diff --git a/openpype/hosts/hiero/plugins/load/load_clip.py b/openpype/hosts/hiero/plugins/load/load_clip.py index 9e12fa360e..b905dd4431 100644 --- a/openpype/hosts/hiero/plugins/load/load_clip.py +++ b/openpype/hosts/hiero/plugins/load/load_clip.py @@ -54,6 +54,10 @@ class LoadClip(phiero.SequenceLoader): object_name = self.clip_name_template.format( **context["representation"]["context"]) + # set colorspace + if colorspace: + track_item.source().setSourceMediaColourTransform(colorspace) + # add additional metadata from the version to imprint Avalon knob add_keys = [ "frameStart", "frameEnd", "source", "author", @@ -109,9 +113,14 @@ class LoadClip(phiero.SequenceLoader): colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) file = api.get_representation_path(representation).replace("\\", "/") + clip = track_item.source() # reconnect media to new path - track_item.source().reconnectMedia(file) + clip.reconnectMedia(file) + + # set colorspace + if colorspace: + clip.setSourceMediaColourTransform(colorspace) # add additional metadata from the version to imprint Avalon knob add_keys = [ @@ -160,6 +169,7 @@ class LoadClip(phiero.SequenceLoader): @classmethod def set_item_color(cls, track_item, version): + clip = track_item.source() # define version name version_name = version.get("name", None) # get all versions in list @@ -172,6 +182,6 @@ class LoadClip(phiero.SequenceLoader): # set clip colour if version_name == max_version: - track_item.source().binItem().setColor(cls.clip_color_last) + clip.binItem().setColor(cls.clip_color_last) else: - track_item.source().binItem().setColor(cls.clip_color) + clip.binItem().setColor(cls.clip_color) diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index 4984849aa7..9b529edf88 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -120,6 +120,13 @@ class PrecollectInstances(pyblish.api.ContextPlugin): # create instance instance = context.create_instance(**data) + # add colorspace data + instance.data.update({ + "versionData": { + "colorspace": track_item.sourceMediaColourTransform(), + } + }) + # create shot instance for shot attributes create/update self.create_shot_instance(context, **data) @@ -133,13 +140,6 @@ class PrecollectInstances(pyblish.api.ContextPlugin): # create audio subset instance self.create_audio_instance(context, **data) - # add colorspace data - instance.data.update({ - "versionData": { - "colorspace": track_item.sourceMediaColourTransform(), - } - }) - # add audioReview attribute to plate instance data # if reviewTrack is on if tag_data.get("reviewTrack") is not None: diff --git a/openpype/hosts/maya/api/__init__.py b/openpype/hosts/maya/api/__init__.py index 4697d212de..9219da407f 100644 --- a/openpype/hosts/maya/api/__init__.py +++ b/openpype/hosts/maya/api/__init__.py @@ -26,6 +26,12 @@ INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") def install(): + from openpype.settings import get_project_settings + + project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) + # process path mapping + process_dirmap(project_settings) + pyblish.register_plugin_path(PUBLISH_PATH) avalon.register_plugin_path(avalon.Loader, LOAD_PATH) avalon.register_plugin_path(avalon.Creator, CREATE_PATH) @@ -53,6 +59,40 @@ def install(): avalon.data["familiesStateToggled"] = ["imagesequence"] +def process_dirmap(project_settings): + # type: (dict) -> None + """Go through all paths in Settings and set them using `dirmap`. + + Args: + project_settings (dict): Settings for current project. + + """ + if not project_settings["maya"].get("maya-dirmap"): + return + mapping = project_settings["maya"]["maya-dirmap"]["paths"] or {} + mapping_enabled = project_settings["maya"]["maya-dirmap"]["enabled"] + if not mapping or not mapping_enabled: + return + if mapping.get("source-path") and mapping_enabled is True: + log.info("Processing directory mapping ...") + cmds.dirmap(en=True) + for k, sp in enumerate(mapping["source-path"]): + try: + print("{} -> {}".format(sp, mapping["destination-path"][k])) + cmds.dirmap(m=(sp, mapping["destination-path"][k])) + cmds.dirmap(m=(mapping["destination-path"][k], sp)) + except IndexError: + # missing corresponding destination path + log.error(("invalid dirmap mapping, missing corresponding" + " destination directory.")) + break + except RuntimeError: + log.error("invalid path {} -> {}, mapping not registered".format( + sp, mapping["destination-path"][k] + )) + continue + + def uninstall(): pyblish.deregister_plugin_path(PUBLISH_PATH) avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) diff --git a/openpype/hosts/maya/api/expected_files.py b/openpype/hosts/maya/api/expected_files.py deleted file mode 100644 index 15e0dc598c..0000000000 --- a/openpype/hosts/maya/api/expected_files.py +++ /dev/null @@ -1,945 +0,0 @@ -# -*- coding: utf-8 -*- -"""Module handling expected render output from Maya. - -This module is used in :mod:`collect_render` and :mod:`collect_vray_scene`. - -Note: - To implement new renderer, just create new class inheriting from - :class:`AExpectedFiles` and add it to :func:`ExpectedFiles.get()`. - -Attributes: - R_SINGLE_FRAME (:class:`re.Pattern`): Find single frame number. - R_FRAME_RANGE (:class:`re.Pattern`): Find frame range. - R_FRAME_NUMBER (:class:`re.Pattern`): Find frame number in string. - R_LAYER_TOKEN (:class:`re.Pattern`): Find layer token in image prefixes. - R_AOV_TOKEN (:class:`re.Pattern`): Find AOV token in image prefixes. - R_SUBSTITUTE_AOV_TOKEN (:class:`re.Pattern`): Find and substitute AOV token - in image prefixes. - R_REMOVE_AOV_TOKEN (:class:`re.Pattern`): Find and remove AOV token in - image prefixes. - R_CLEAN_FRAME_TOKEN (:class:`re.Pattern`): Find and remove unfilled - Renderman frame token in image prefix. - R_CLEAN_EXT_TOKEN (:class:`re.Pattern`): Find and remove unfilled Renderman - extension token in image prefix. - R_SUBSTITUTE_LAYER_TOKEN (:class:`re.Pattern`): Find and substitute render - layer token in image prefixes. - R_SUBSTITUTE_SCENE_TOKEN (:class:`re.Pattern`): Find and substitute scene - token in image prefixes. - R_SUBSTITUTE_CAMERA_TOKEN (:class:`re.Pattern`): Find and substitute camera - token in image prefixes. - RENDERER_NAMES (dict): Renderer names mapping between reported name and - *human readable* name. - IMAGE_PREFIXES (dict): Mapping between renderers and their respective - image prefix attribute names. - -Todo: - Determine `multipart` from render instance. - -""" - -import types -import re -import os -from abc import ABCMeta, abstractmethod - -import six -import attr - -import openpype.hosts.maya.api.lib as lib - -from maya import cmds -import maya.app.renderSetup.model.renderSetup as renderSetup - - -R_SINGLE_FRAME = re.compile(r"^(-?)\d+$") -R_FRAME_RANGE = re.compile(r"^(?P(-?)\d+)-(?P(-?)\d+)$") -R_FRAME_NUMBER = re.compile(r".+\.(?P[0-9]+)\..+") -R_LAYER_TOKEN = re.compile( - r".*((?:%l)|(?:)|(?:)).*", re.IGNORECASE -) -R_AOV_TOKEN = re.compile(r".*%a.*|.*.*|.*.*", re.IGNORECASE) -R_SUBSTITUTE_AOV_TOKEN = re.compile(r"%a||", re.IGNORECASE) -R_REMOVE_AOV_TOKEN = re.compile( - r"_%a|\.%a|_|\.|_|\.", re.IGNORECASE) -# to remove unused renderman tokens -R_CLEAN_FRAME_TOKEN = re.compile(r"\.?\.?", re.IGNORECASE) -R_CLEAN_EXT_TOKEN = re.compile(r"\.?\.?", re.IGNORECASE) - -R_SUBSTITUTE_LAYER_TOKEN = re.compile( - r"%l||", re.IGNORECASE -) -R_SUBSTITUTE_CAMERA_TOKEN = re.compile(r"%c|", re.IGNORECASE) -R_SUBSTITUTE_SCENE_TOKEN = re.compile(r"%s|", re.IGNORECASE) - -RENDERER_NAMES = { - "mentalray": "MentalRay", - "vray": "V-Ray", - "arnold": "Arnold", - "renderman": "Renderman", - "redshift": "Redshift", -} - -# not sure about the renderman image prefix -IMAGE_PREFIXES = { - "mentalray": "defaultRenderGlobals.imageFilePrefix", - "vray": "vraySettings.fileNamePrefix", - "arnold": "defaultRenderGlobals.imageFilePrefix", - "renderman": "rmanGlobals.imageFileFormat", - "redshift": "defaultRenderGlobals.imageFilePrefix", -} - - -@attr.s -class LayerMetadata(object): - """Data class for Render Layer metadata.""" - frameStart = attr.ib() - frameEnd = attr.ib() - cameras = attr.ib() - sceneName = attr.ib() - layerName = attr.ib() - renderer = attr.ib() - defaultExt = attr.ib() - filePrefix = attr.ib() - enabledAOVs = attr.ib() - frameStep = attr.ib(default=1) - padding = attr.ib(default=4) - - -class ExpectedFiles: - """Class grouping functionality for all supported renderers. - - Attributes: - multipart (bool): Flag if multipart exrs are used. - - """ - multipart = False - - def __init__(self, render_instance): - """Constructor.""" - self._render_instance = render_instance - - def get(self, renderer, layer): - """Get expected files for given renderer and render layer. - - Args: - renderer (str): Name of renderer - layer (str): Name of render layer - - Returns: - dict: Expected rendered files by AOV - - Raises: - :exc:`UnsupportedRendererException`: If requested renderer - is not supported. It needs to be implemented by extending - :class:`AExpectedFiles` and added to this methods ``if`` - statement. - - """ - renderSetup.instance().switchToLayerUsingLegacyName(layer) - - if renderer.lower() == "arnold": - return self._get_files(ExpectedFilesArnold(layer, - self._render_instance)) - if renderer.lower() == "vray": - return self._get_files(ExpectedFilesVray( - layer, self._render_instance)) - if renderer.lower() == "redshift": - return self._get_files(ExpectedFilesRedshift( - layer, self._render_instance)) - if renderer.lower() == "mentalray": - return self._get_files(ExpectedFilesMentalray( - layer, self._render_instance)) - if renderer.lower() == "renderman": - return self._get_files(ExpectedFilesRenderman( - layer, self._render_instance)) - - raise UnsupportedRendererException( - "unsupported {}".format(renderer) - ) - - def _get_files(self, renderer): - # type: (AExpectedFiles) -> list - files = renderer.get_files() - self.multipart = renderer.multipart - return files - - -@six.add_metaclass(ABCMeta) -class AExpectedFiles: - """Abstract class with common code for all renderers. - - Attributes: - renderer (str): name of renderer. - layer (str): name of render layer. - multipart (bool): flag for multipart exrs. - - """ - - renderer = None - layer = None - multipart = False - - def __init__(self, layer, render_instance): - """Constructor.""" - self.layer = layer - self.render_instance = render_instance - - @abstractmethod - def get_aovs(self): - """To be implemented by renderer class.""" - - @staticmethod - def sanitize_camera_name(camera): - """Sanitize camera name. - - Remove Maya illegal characters from camera name. - - Args: - camera (str): Maya camera name. - - Returns: - (str): sanitized camera name - - Example: - >>> AExpectedFiles.sanizite_camera_name('test:camera_01') - test_camera_01 - - """ - return re.sub('[^0-9a-zA-Z_]+', '_', camera) - - def get_renderer_prefix(self): - """Return prefix for specific renderer. - - This is for most renderers the same and can be overridden if needed. - - Returns: - str: String with image prefix containing tokens - - Raises: - :exc:`UnsupportedRendererException`: If we requested image - prefix for renderer we know nothing about. - See :data:`IMAGE_PREFIXES` for mapping of renderers and - image prefixes. - - """ - try: - file_prefix = cmds.getAttr(IMAGE_PREFIXES[self.renderer]) - except KeyError: - raise UnsupportedRendererException( - "Unsupported renderer {}".format(self.renderer) - ) - return file_prefix - - def _get_layer_data(self): - # type: () -> LayerMetadata - # ______________________________________________ - # ____________________/ ____________________________________________/ - # 1 - get scene name /__________________/ - # ____________________/ - _, scene_basename = os.path.split(cmds.file(q=True, loc=True)) - scene_name, _ = os.path.splitext(scene_basename) - - file_prefix = self.get_renderer_prefix() - - if not file_prefix: - raise RuntimeError("Image prefix not set") - - layer_name = self.layer - if self.layer.startswith("rs_"): - layer_name = self.layer[3:] - - return LayerMetadata( - frameStart=int(self.get_render_attribute("startFrame")), - frameEnd=int(self.get_render_attribute("endFrame")), - frameStep=int(self.get_render_attribute("byFrameStep")), - padding=int(self.get_render_attribute("extensionPadding")), - # if we have token in prefix path we'll expect output for - # every renderable camera in layer. - cameras=self.get_renderable_cameras(), - sceneName=scene_name, - layerName=layer_name, - renderer=self.renderer, - defaultExt=cmds.getAttr("defaultRenderGlobals.imfPluginKey"), - filePrefix=file_prefix, - enabledAOVs=self.get_aovs() - ) - - def _generate_single_file_sequence( - self, layer_data, force_aov_name=None): - # type: (LayerMetadata, str) -> list - expected_files = [] - for cam in layer_data.cameras: - file_prefix = layer_data.filePrefix - mappings = ( - (R_SUBSTITUTE_SCENE_TOKEN, layer_data.sceneName), - (R_SUBSTITUTE_LAYER_TOKEN, layer_data.layerName), - (R_SUBSTITUTE_CAMERA_TOKEN, self.sanitize_camera_name(cam)), - # this is required to remove unfilled aov token, for example - # in Redshift - (R_REMOVE_AOV_TOKEN, "") if not force_aov_name \ - else (R_SUBSTITUTE_AOV_TOKEN, force_aov_name), - - (R_CLEAN_FRAME_TOKEN, ""), - (R_CLEAN_EXT_TOKEN, ""), - ) - - for regex, value in mappings: - file_prefix = re.sub(regex, value, file_prefix) - - for frame in range( - int(layer_data.frameStart), - int(layer_data.frameEnd) + 1, - int(layer_data.frameStep), - ): - expected_files.append( - "{}.{}.{}".format( - file_prefix, - str(frame).rjust(layer_data.padding, "0"), - layer_data.defaultExt, - ) - ) - return expected_files - - def _generate_aov_file_sequences(self, layer_data): - # type: (LayerMetadata) -> list - expected_files = [] - aov_file_list = {} - for aov in layer_data.enabledAOVs: - for cam in layer_data.cameras: - file_prefix = layer_data.filePrefix - - mappings = ( - (R_SUBSTITUTE_SCENE_TOKEN, layer_data.sceneName), - (R_SUBSTITUTE_LAYER_TOKEN, layer_data.layerName), - (R_SUBSTITUTE_CAMERA_TOKEN, - self.sanitize_camera_name(cam)), - (R_SUBSTITUTE_AOV_TOKEN, aov[0]), - (R_CLEAN_FRAME_TOKEN, ""), - (R_CLEAN_EXT_TOKEN, ""), - ) - - for regex, value in mappings: - file_prefix = re.sub(regex, value, file_prefix) - - aov_files = [] - for frame in range( - int(layer_data.frameStart), - int(layer_data.frameEnd) + 1, - int(layer_data.frameStep), - ): - aov_files.append( - "{}.{}.{}".format( - file_prefix, - str(frame).rjust(layer_data.padding, "0"), - aov[1], - ) - ) - - # if we have more then one renderable camera, append - # camera name to AOV to allow per camera AOVs. - aov_name = aov[0] - if len(layer_data.cameras) > 1: - aov_name = "{}_{}".format(aov[0], - self.sanitize_camera_name(cam)) - - aov_file_list[aov_name] = aov_files - file_prefix = layer_data.filePrefix - - expected_files.append(aov_file_list) - return expected_files - - def get_files(self): - """Return list of expected files. - - It will translate render token strings ('', etc.) to - their values. This task is tricky as every renderer deals with this - differently. It depends on `get_aovs()` abstract method implemented - for every supported renderer. - - """ - layer_data = self._get_layer_data() - - expected_files = [] - if layer_data.enabledAOVs: - return self._generate_aov_file_sequences(layer_data) - else: - return self._generate_single_file_sequence(layer_data) - - def get_renderable_cameras(self): - # type: () -> list - """Get all renderable cameras. - - Returns: - list: list of renderable cameras. - - """ - cam_parents = [ - cmds.listRelatives(x, ap=True)[-1] for x in cmds.ls(cameras=True) - ] - - return [ - cam - for cam in cam_parents - if self.maya_is_true(cmds.getAttr("{}.renderable".format(cam))) - ] - - @staticmethod - def maya_is_true(attr_val): - """Whether a Maya attr evaluates to True. - - When querying an attribute value from an ambiguous object the - Maya API will return a list of values, which need to be properly - handled to evaluate properly. - - Args: - attr_val (mixed): Maya attribute to be evaluated as bool. - - Returns: - bool: cast Maya attribute to Pythons boolean value. - - """ - if isinstance(attr_val, types.BooleanType): - return attr_val - if isinstance(attr_val, (types.ListType, types.GeneratorType)): - return any(attr_val) - - return bool(attr_val) - - @staticmethod - def get_layer_overrides(attribute): - """Get overrides for attribute on current render layer. - - Args: - attribute (str): Maya attribute name. - - Returns: - Value of attribute override. - - """ - connections = cmds.listConnections(attribute, plugs=True) - if connections: - for connection in connections: - if connection: - # node_name = connection.split(".")[0] - - attr_name = "%s.value" % ".".join( - connection.split(".")[:-1] - ) - yield cmds.getAttr(attr_name) - - def get_render_attribute(self, attribute): - """Get attribute from render options. - - Args: - attribute (str): name of attribute to be looked up. - - Returns: - Attribute value - - """ - return lib.get_attr_in_layer( - "defaultRenderGlobals.{}".format(attribute), layer=self.layer - ) - - -class ExpectedFilesArnold(AExpectedFiles): - """Expected files for Arnold renderer. - - Attributes: - aiDriverExtension (dict): Arnold AOV driver extension mapping. - Is there a better way? - renderer (str): name of renderer. - - """ - - aiDriverExtension = { - "jpeg": "jpg", - "exr": "exr", - "deepexr": "exr", - "png": "png", - "tiff": "tif", - "mtoa_shaders": "ass", # TODO: research what those last two should be - "maya": "", - } - - def __init__(self, layer, render_instance): - """Constructor.""" - super(ExpectedFilesArnold, self).__init__(layer, render_instance) - self.renderer = "arnold" - - def get_aovs(self): - """Get all AOVs. - - See Also: - :func:`AExpectedFiles.get_aovs()` - - Raises: - :class:`AOVError`: If AOV cannot be determined. - - """ - enabled_aovs = [] - try: - if not ( - cmds.getAttr("defaultArnoldRenderOptions.aovMode") - and not cmds.getAttr("defaultArnoldDriver.mergeAOVs") # noqa: W503, E501 - ): - # AOVs are merged in mutli-channel file - self.multipart = True - return enabled_aovs - except ValueError: - # this occurs when Render Setting windows was not opened yet. In - # such case there are no Arnold options created so query for AOVs - # will fail. We terminate here as there are no AOVs specified then. - # This state will most probably fail later on some Validator - # anyway. - return enabled_aovs - - # AOVs are set to be rendered separately. We should expect - # token in path. - - # handle aovs from references - use_ref_aovs = self.render_instance.data.get( - "useReferencedAovs", False) or False - - ai_aovs = cmds.ls(type="aiAOV") - if not use_ref_aovs: - ref_aovs = cmds.ls(type="aiAOV", referencedNodes=True) - ai_aovs = list(set(ai_aovs) - set(ref_aovs)) - - for aov in ai_aovs: - enabled = self.maya_is_true(cmds.getAttr("{}.enabled".format(aov))) - ai_driver = cmds.listConnections("{}.outputs".format(aov))[0] - ai_translator = cmds.getAttr("{}.aiTranslator".format(ai_driver)) - try: - aov_ext = self.aiDriverExtension[ai_translator] - except KeyError: - msg = ( - "Unrecognized arnold " "driver format for AOV - {}" - ).format(cmds.getAttr("{}.name".format(aov))) - raise AOVError(msg) - - for override in self.get_layer_overrides( - "{}.enabled".format(aov) - ): - enabled = self.maya_is_true(override) - if enabled: - # If aov RGBA is selected, arnold will translate it to `beauty` - aov_name = cmds.getAttr("%s.name" % aov) - if aov_name == "RGBA": - aov_name = "beauty" - enabled_aovs.append((aov_name, aov_ext)) - # Append 'beauty' as this is arnolds - # default. If token is specified and no AOVs are - # defined, this will be used. - enabled_aovs.append( - (u"beauty", cmds.getAttr("defaultRenderGlobals.imfPluginKey")) - ) - return enabled_aovs - - -class ExpectedFilesVray(AExpectedFiles): - """Expected files for V-Ray renderer.""" - - def __init__(self, layer, render_instance): - """Constructor.""" - super(ExpectedFilesVray, self).__init__(layer, render_instance) - self.renderer = "vray" - - def get_renderer_prefix(self): - """Get image prefix for V-Ray. - - This overrides :func:`AExpectedFiles.get_renderer_prefix()` as - we must add `` token manually. - - See also: - :func:`AExpectedFiles.get_renderer_prefix()` - - """ - prefix = super(ExpectedFilesVray, self).get_renderer_prefix() - prefix = "{}_".format(prefix) - return prefix - - def _get_layer_data(self): - # type: () -> LayerMetadata - """Override to get vray specific extension.""" - layer_data = super(ExpectedFilesVray, self)._get_layer_data() - default_ext = cmds.getAttr("vraySettings.imageFormatStr") - if default_ext in ["exr (multichannel)", "exr (deep)"]: - default_ext = "exr" - layer_data.defaultExt = default_ext - layer_data.padding = cmds.getAttr("vraySettings.fileNamePadding") - return layer_data - - def get_files(self): - """Get expected files. - - This overrides :func:`AExpectedFiles.get_files()` as we - we need to add one sequence for plain beauty if AOVs are enabled - as vray output beauty without 'beauty' in filename. - - """ - expected_files = super(ExpectedFilesVray, self).get_files() - - layer_data = self._get_layer_data() - # remove 'beauty' from filenames as vray doesn't output it - update = {} - if layer_data.enabledAOVs: - for aov, seqs in expected_files[0].items(): - if aov.startswith("beauty"): - new_list = [] - for seq in seqs: - new_list.append(seq.replace("_beauty", "")) - update[aov] = new_list - - expected_files[0].update(update) - return expected_files - - def get_aovs(self): - """Get all AOVs. - - See Also: - :func:`AExpectedFiles.get_aovs()` - - """ - enabled_aovs = [] - - try: - # really? do we set it in vray just by selecting multichannel exr? - if ( - cmds.getAttr("vraySettings.imageFormatStr") - == "exr (multichannel)" # noqa: W503 - ): - # AOVs are merged in mutli-channel file - self.multipart = True - return enabled_aovs - except ValueError: - # this occurs when Render Setting windows was not opened yet. In - # such case there are no VRay options created so query for AOVs - # will fail. We terminate here as there are no AOVs specified then. - # This state will most probably fail later on some Validator - # anyway. - return enabled_aovs - - default_ext = cmds.getAttr("vraySettings.imageFormatStr") - if default_ext in ["exr (multichannel)", "exr (deep)"]: - default_ext = "exr" - - # add beauty as default - enabled_aovs.append( - (u"beauty", default_ext) - ) - - # handle aovs from references - use_ref_aovs = self.render_instance.data.get( - "useReferencedAovs", False) or False - - # this will have list of all aovs no matter if they are coming from - # reference or not. - vr_aovs = cmds.ls( - type=["VRayRenderElement", "VRayRenderElementSet"]) or [] - if not use_ref_aovs: - ref_aovs = cmds.ls( - type=["VRayRenderElement", "VRayRenderElementSet"], - referencedNodes=True) or [] - # get difference - vr_aovs = list(set(vr_aovs) - set(ref_aovs)) - - for aov in vr_aovs: - enabled = self.maya_is_true(cmds.getAttr("{}.enabled".format(aov))) - for override in self.get_layer_overrides( - "{}.enabled".format(aov) - ): - enabled = self.maya_is_true(override) - - if enabled: - enabled_aovs.append( - (self._get_vray_aov_name(aov), default_ext)) - - return enabled_aovs - - @staticmethod - def _get_vray_aov_name(node): - """Get AOVs name from Vray. - - Args: - node (str): aov node name. - - Returns: - str: aov name. - - """ - vray_name = None - vray_explicit_name = None - vray_file_name = None - for node_attr in cmds.listAttr(node): - if node_attr.startswith("vray_filename"): - vray_file_name = cmds.getAttr("{}.{}".format(node, node_attr)) - elif node_attr.startswith("vray_name"): - vray_name = cmds.getAttr("{}.{}".format(node, node_attr)) - elif node_attr.startswith("vray_explicit_name"): - vray_explicit_name = cmds.getAttr( - "{}.{}".format(node, node_attr)) - - if vray_file_name is not None and vray_file_name != "": - final_name = vray_file_name - elif vray_explicit_name is not None and vray_explicit_name != "": - final_name = vray_explicit_name - elif vray_name is not None and vray_name != "": - final_name = vray_name - else: - continue - # special case for Material Select elements - these are named - # based on the materia they are connected to. - if "vray_mtl_mtlselect" in cmds.listAttr(node): - connections = cmds.listConnections( - "{}.vray_mtl_mtlselect".format(node)) - if connections: - final_name += '_{}'.format(str(connections[0])) - - return final_name - - -class ExpectedFilesRedshift(AExpectedFiles): - """Expected files for Redshift renderer. - - Attributes: - - unmerged_aovs (list): Name of aovs that are not merged into resulting - exr and we need them specified in expectedFiles output. - - """ - - unmerged_aovs = ["Cryptomatte"] - - def __init__(self, layer, render_instance): - """Construtor.""" - super(ExpectedFilesRedshift, self).__init__(layer, render_instance) - self.renderer = "redshift" - - def get_renderer_prefix(self): - """Get image prefix for Redshift. - - This overrides :func:`AExpectedFiles.get_renderer_prefix()` as - we must add `` token manually. - - See also: - :func:`AExpectedFiles.get_renderer_prefix()` - - """ - prefix = super(ExpectedFilesRedshift, self).get_renderer_prefix() - prefix = "{}.".format(prefix) - return prefix - - def get_files(self): - """Get expected files. - - This overrides :func:`AExpectedFiles.get_files()` as we - we need to add one sequence for plain beauty if AOVs are enabled - as vray output beauty without 'beauty' in filename. - - """ - expected_files = super(ExpectedFilesRedshift, self).get_files() - layer_data = self._get_layer_data() - - # Redshift doesn't merge Cryptomatte AOV to final exr. We need to check - # for such condition and add it to list of expected files. - - for aov in layer_data.enabledAOVs: - if aov[0].lower() == "cryptomatte": - aov_name = aov[0] - expected_files.append( - {aov_name: self._generate_single_file_sequence(layer_data)} - ) - - if layer_data.get("enabledAOVs"): - # because if Beauty is added manually, it will be rendered as - # 'Beauty_other' in file name and "standard" beauty will have - # 'Beauty' in its name. When disabled, standard output will be - # without `Beauty`. - if expected_files[0].get(u"Beauty"): - expected_files[0][u"Beauty_other"] = expected_files[0].pop( - u"Beauty") - new_list = [ - seq.replace(".Beauty", ".Beauty_other") - for seq in expected_files[0][u"Beauty_other"] - ] - - expected_files[0][u"Beauty_other"] = new_list - expected_files[0][u"Beauty"] = self._generate_single_file_sequence( # noqa: E501 - layer_data, force_aov_name="Beauty" - ) - else: - expected_files[0][u"Beauty"] = self._generate_single_file_sequence( # noqa: E501 - layer_data - ) - - return expected_files - - def get_aovs(self): - """Get all AOVs. - - See Also: - :func:`AExpectedFiles.get_aovs()` - - """ - enabled_aovs = [] - - try: - if self.maya_is_true( - cmds.getAttr("redshiftOptions.exrForceMultilayer") - ): - # AOVs are merged in mutli-channel file - self.multipart = True - return enabled_aovs - except ValueError: - # this occurs when Render Setting windows was not opened yet. In - # such case there are no Redshift options created so query for AOVs - # will fail. We terminate here as there are no AOVs specified then. - # This state will most probably fail later on some Validator - # anyway. - return enabled_aovs - - default_ext = cmds.getAttr( - "redshiftOptions.imageFormat", asString=True) - rs_aovs = cmds.ls(type="RedshiftAOV", referencedNodes=False) - - for aov in rs_aovs: - enabled = self.maya_is_true(cmds.getAttr("{}.enabled".format(aov))) - for override in self.get_layer_overrides( - "{}.enabled".format(aov) - ): - enabled = self.maya_is_true(override) - - if enabled: - # If AOVs are merged into multipart exr, append AOV only if it - # is in the list of AOVs that renderer cannot (or will not) - # merge into final exr. - if self.maya_is_true( - cmds.getAttr("redshiftOptions.exrForceMultilayer") - ): - if cmds.getAttr("%s.name" % aov) in self.unmerged_aovs: - enabled_aovs.append( - (cmds.getAttr("%s.name" % aov), default_ext) - ) - else: - enabled_aovs.append( - (cmds.getAttr("%s.name" % aov), default_ext) - ) - - if self.maya_is_true( - cmds.getAttr("redshiftOptions.exrForceMultilayer") - ): - # AOVs are merged in mutli-channel file - self.multipart = True - - return enabled_aovs - - -class ExpectedFilesRenderman(AExpectedFiles): - """Expected files for Renderman renderer. - - Warning: - This is very rudimentary and needs more love and testing. - """ - - def __init__(self, layer, render_instance): - """Constructor.""" - super(ExpectedFilesRenderman, self).__init__(layer, render_instance) - self.renderer = "renderman" - - def get_aovs(self): - """Get all AOVs. - - See Also: - :func:`AExpectedFiles.get_aovs()` - - """ - enabled_aovs = [] - - default_ext = "exr" - displays = cmds.listConnections("rmanGlobals.displays") - for aov in displays: - aov_name = str(aov) - if aov_name == "rmanDefaultDisplay": - aov_name = "beauty" - - enabled = self.maya_is_true(cmds.getAttr("{}.enable".format(aov))) - for override in self.get_layer_overrides( - "{}.enable".format(aov) - ): - enabled = self.maya_is_true(override) - - if enabled: - enabled_aovs.append((aov_name, default_ext)) - - return enabled_aovs - - def get_files(self): - """Get expected files. - - This overrides :func:`AExpectedFiles.get_files()` as we - we need to add one sequence for plain beauty if AOVs are enabled - as vray output beauty without 'beauty' in filename. - - In renderman we hack it with prepending path. This path would - normally be translated from `rmanGlobals.imageOutputDir`. We skip - this and hardcode prepend path we expect. There is no place for user - to mess around with this settings anyway and it is enforced in - render settings validator. - """ - layer_data = self._get_layer_data() - new_aovs = {} - - expected_files = super(ExpectedFilesRenderman, self).get_files() - # we always get beauty - for aov, files in expected_files[0].items(): - new_files = [] - for file in files: - new_file = "{}/{}/{}".format( - layer_data["sceneName"], layer_data["layerName"], file - ) - new_files.append(new_file) - new_aovs[aov] = new_files - - return [new_aovs] - - -class ExpectedFilesMentalray(AExpectedFiles): - """Skeleton unimplemented class for Mentalray renderer.""" - - def __init__(self, layer, render_instance): - """Constructor. - - Raises: - :exc:`UnimplementedRendererException`: as it is not implemented. - - """ - super(ExpectedFilesMentalray, self).__init__(layer, render_instance) - raise UnimplementedRendererException("Mentalray not implemented") - - def get_aovs(self): - """Get all AOVs. - - See Also: - :func:`AExpectedFiles.get_aovs()` - - """ - return [] - - -class AOVError(Exception): - """Custom exception for determining AOVs.""" - - -class UnsupportedRendererException(Exception): - """Custom exception. - - Raised when requesting data from unsupported renderer. - """ - - -class UnimplementedRendererException(Exception): - """Custom exception. - - Raised when requesting data from renderer that is not implemented yet. - """ diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index b87e106865..b24235447f 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -2252,10 +2252,8 @@ def get_attr_in_layer(attr, layer): try: if cmds.mayaHasRenderSetup(): - log.debug("lib.get_attr_in_layer is not " - "optimized for render setup") - with renderlayer(layer): - return cmds.getAttr(attr) + from . import lib_rendersetup + return lib_rendersetup.get_attr_in_layer(attr, layer) except AttributeError: pass diff --git a/openpype/hosts/maya/api/lib_renderproducts.py b/openpype/hosts/maya/api/lib_renderproducts.py new file mode 100644 index 0000000000..fb99584c5d --- /dev/null +++ b/openpype/hosts/maya/api/lib_renderproducts.py @@ -0,0 +1,1039 @@ +# -*- coding: utf-8 -*- +"""Module handling expected render output from Maya. + +This module is used in :mod:`collect_render` and :mod:`collect_vray_scene`. + +Note: + To implement new renderer, just create new class inheriting from + :class:`ARenderProducts` and add it to :func:`RenderProducts.get()`. + +Attributes: + R_SINGLE_FRAME (:class:`re.Pattern`): Find single frame number. + R_FRAME_RANGE (:class:`re.Pattern`): Find frame range. + R_FRAME_NUMBER (:class:`re.Pattern`): Find frame number in string. + R_LAYER_TOKEN (:class:`re.Pattern`): Find layer token in image prefixes. + R_AOV_TOKEN (:class:`re.Pattern`): Find AOV token in image prefixes. + R_SUBSTITUTE_AOV_TOKEN (:class:`re.Pattern`): Find and substitute AOV token + in image prefixes. + R_REMOVE_AOV_TOKEN (:class:`re.Pattern`): Find and remove AOV token in + image prefixes. + R_CLEAN_FRAME_TOKEN (:class:`re.Pattern`): Find and remove unfilled + Renderman frame token in image prefix. + R_CLEAN_EXT_TOKEN (:class:`re.Pattern`): Find and remove unfilled Renderman + extension token in image prefix. + R_SUBSTITUTE_LAYER_TOKEN (:class:`re.Pattern`): Find and substitute render + layer token in image prefixes. + R_SUBSTITUTE_SCENE_TOKEN (:class:`re.Pattern`): Find and substitute scene + token in image prefixes. + R_SUBSTITUTE_CAMERA_TOKEN (:class:`re.Pattern`): Find and substitute camera + token in image prefixes. + IMAGE_PREFIXES (dict): Mapping between renderers and their respective + image prefix attribute names. + +Thanks: + Roy Nieterau (BigRoy) / Colorbleed for overhaul of original + *expected_files*. + +""" + +import logging +import re +import os +from abc import ABCMeta, abstractmethod + +import six +import attr + +from . import lib +from . import lib_rendersetup + +from maya import cmds, mel + +log = logging.getLogger(__name__) + +R_SINGLE_FRAME = re.compile(r"^(-?)\d+$") +R_FRAME_RANGE = re.compile(r"^(?P(-?)\d+)-(?P(-?)\d+)$") +R_FRAME_NUMBER = re.compile(r".+\.(?P[0-9]+)\..+") +R_LAYER_TOKEN = re.compile( + r".*((?:%l)|(?:)|(?:)).*", re.IGNORECASE +) +R_AOV_TOKEN = re.compile(r".*%a.*|.*.*|.*.*", re.IGNORECASE) +R_SUBSTITUTE_AOV_TOKEN = re.compile(r"%a||", re.IGNORECASE) +R_REMOVE_AOV_TOKEN = re.compile( + r"_%a|\.%a|_|\.|_|\.", re.IGNORECASE) +# to remove unused renderman tokens +R_CLEAN_FRAME_TOKEN = re.compile(r"\.?\.?", re.IGNORECASE) +R_CLEAN_EXT_TOKEN = re.compile(r"\.?\.?", re.IGNORECASE) + +R_SUBSTITUTE_LAYER_TOKEN = re.compile( + r"%l||", re.IGNORECASE +) +R_SUBSTITUTE_CAMERA_TOKEN = re.compile(r"%c|", re.IGNORECASE) +R_SUBSTITUTE_SCENE_TOKEN = re.compile(r"%s|", re.IGNORECASE) + +# not sure about the renderman image prefix +IMAGE_PREFIXES = { + "vray": "vraySettings.fileNamePrefix", + "arnold": "defaultRenderGlobals.imageFilePrefix", + "renderman": "rmanGlobals.imageFileFormat", + "redshift": "defaultRenderGlobals.imageFilePrefix", +} + + +@attr.s +class LayerMetadata(object): + """Data class for Render Layer metadata.""" + frameStart = attr.ib() + frameEnd = attr.ib() + cameras = attr.ib() + sceneName = attr.ib() + layerName = attr.ib() + renderer = attr.ib() + defaultExt = attr.ib() + filePrefix = attr.ib() + frameStep = attr.ib(default=1) + padding = attr.ib(default=4) + + # Render Products + products = attr.ib(init=False, default=attr.Factory(list)) + + +@attr.s +class RenderProduct(object): + """Describes an image or other file-like artifact produced by a render. + + Warning: + This currently does NOT return as a product PER render camera. + A single Render Product will generate files per camera. E.g. with two + cameras each render product generates two sequences on disk assuming + the file path prefix correctly uses the tokens. + + """ + productName = attr.ib() + ext = attr.ib() # extension + aov = attr.ib(default=None) # source aov + driver = attr.ib(default=None) # source driver + multipart = attr.ib(default=False) # multichannel file + + +def get(layer, render_instance=None): + # type: (str, object) -> ARenderProducts + """Get render details and products for given renderer and render layer. + + Args: + layer (str): Name of render layer + render_instance (pyblish.api.Instance): Publish instance. + If not provided an empty mock instance is used. + + Returns: + ARenderProducts: The correct RenderProducts instance for that + renderlayer. + + Raises: + :exc:`UnsupportedRendererException`: If requested renderer + is not supported. It needs to be implemented by extending + :class:`ARenderProducts` and added to this methods ``if`` + statement. + + """ + + if render_instance is None: + # For now produce a mock instance + class Instance(object): + data = {} + render_instance = Instance() + + renderer_name = lib.get_attr_in_layer( + "defaultRenderGlobals.currentRenderer", + layer=layer + ) + + renderer = { + "arnold": RenderProductsArnold, + "vray": RenderProductsVray, + "redshift": RenderProductsRedshift, + "renderman": RenderProductsRenderman + }.get(renderer_name.lower(), None) + if renderer is None: + raise UnsupportedRendererException( + "unsupported {}".format(renderer_name) + ) + + return renderer(layer, render_instance) + + +@six.add_metaclass(ABCMeta) +class ARenderProducts: + """Abstract class with common code for all renderers. + + Attributes: + renderer (str): name of renderer. + + """ + + renderer = None + + def __init__(self, layer, render_instance): + """Constructor.""" + self.layer = layer + self.render_instance = render_instance + self.multipart = False + + # Initialize + self.layer_data = self._get_layer_data() + self.layer_data.products = self.get_render_products() + + @abstractmethod + def get_render_products(self): + """To be implemented by renderer class. + + This should return a list of RenderProducts. + + Returns: + list: List of RenderProduct + + """ + + @staticmethod + def sanitize_camera_name(camera): + # type: (str) -> str + """Sanitize camera name. + + Remove Maya illegal characters from camera name. + + Args: + camera (str): Maya camera name. + + Returns: + (str): sanitized camera name + + Example: + >>> ARenderProducts.sanizite_camera_name('test:camera_01') + test_camera_01 + + """ + return re.sub('[^0-9a-zA-Z_]+', '_', camera) + + def get_renderer_prefix(self): + # type: () -> str + """Return prefix for specific renderer. + + This is for most renderers the same and can be overridden if needed. + + Returns: + str: String with image prefix containing tokens + + Raises: + :exc:`UnsupportedRendererException`: If we requested image + prefix for renderer we know nothing about. + See :data:`IMAGE_PREFIXES` for mapping of renderers and + image prefixes. + + """ + try: + file_prefix_attr = IMAGE_PREFIXES[self.renderer] + except KeyError: + raise UnsupportedRendererException( + "Unsupported renderer {}".format(self.renderer) + ) + + file_prefix = self._get_attr(file_prefix_attr) + + if not file_prefix: + # Fall back to scene name by default + log.debug("Image prefix not set, using ") + file_prefix = "" + + return file_prefix + + def get_render_attribute(self, attribute): + """Get attribute from render options. + + Args: + attribute (str): name of attribute to be looked up. + + Returns: + Attribute value + + """ + return self._get_attr("defaultRenderGlobals", attribute) + + def _get_attr(self, node_attr, attribute=None): + """Return the value of the attribute in the renderlayer + + For readability this allows passing in the attribute in two ways. + + As a single argument: + _get_attr("node.attr") + Or as two arguments: + _get_attr("node", "attr") + + Returns: + Value of the attribute inside the layer this instance is set to. + + """ + + if attribute is None: + plug = node_attr + else: + plug = "{}.{}".format(node_attr, attribute) + + return lib.get_attr_in_layer(plug, layer=self.layer) + + def _get_layer_data(self): + # type: () -> LayerMetadata + # ______________________________________________ + # ____________________/ ____________________________________________/ + # 1 - get scene name /__________________/ + # ____________________/ + _, scene_basename = os.path.split(cmds.file(q=True, loc=True)) + scene_name, _ = os.path.splitext(scene_basename) + + file_prefix = self.get_renderer_prefix() + + # If the Render Layer belongs to a Render Setup layer then the + # output name is based on the Render Setup Layer name without + # the `rs_` prefix. + layer_name = self.layer + rs_layer = lib_rendersetup.get_rendersetup_layer(layer_name) + if rs_layer: + layer_name = rs_layer + + if self.layer == "defaultRenderLayer": + # defaultRenderLayer renders as masterLayer + layer_name = "masterLayer" + + # todo: Support Custom Frames sequences 0,5-10,100-120 + # Deadline allows submitting renders with a custom frame list + # to support those cases we might want to allow 'custom frames' + # to be overridden to `ExpectFiles` class? + layer_data = LayerMetadata( + frameStart=int(self.get_render_attribute("startFrame")), + frameEnd=int(self.get_render_attribute("endFrame")), + frameStep=int(self.get_render_attribute("byFrameStep")), + padding=int(self.get_render_attribute("extensionPadding")), + # if we have token in prefix path we'll expect output for + # every renderable camera in layer. + cameras=self.get_renderable_cameras(), + sceneName=scene_name, + layerName=layer_name, + renderer=self.renderer, + defaultExt=self._get_attr("defaultRenderGlobals.imfPluginKey"), + filePrefix=file_prefix + ) + return layer_data + + def _generate_file_sequence( + self, layer_data, + force_aov_name=None, + force_ext=None, + force_cameras=None): + # type: (LayerMetadata, str, str, list) -> list + expected_files = [] + cameras = force_cameras if force_cameras else layer_data.cameras + ext = force_ext or layer_data.defaultExt + for cam in cameras: + file_prefix = layer_data.filePrefix + mappings = ( + (R_SUBSTITUTE_SCENE_TOKEN, layer_data.sceneName), + (R_SUBSTITUTE_LAYER_TOKEN, layer_data.layerName), + (R_SUBSTITUTE_CAMERA_TOKEN, self.sanitize_camera_name(cam)), + # this is required to remove unfilled aov token, for example + # in Redshift + (R_REMOVE_AOV_TOKEN, "") if not force_aov_name \ + else (R_SUBSTITUTE_AOV_TOKEN, force_aov_name), + + (R_CLEAN_FRAME_TOKEN, ""), + (R_CLEAN_EXT_TOKEN, ""), + ) + + for regex, value in mappings: + file_prefix = re.sub(regex, value, file_prefix) + + for frame in range( + int(layer_data.frameStart), + int(layer_data.frameEnd) + 1, + int(layer_data.frameStep), + ): + frame_str = str(frame).rjust(layer_data.padding, "0") + expected_files.append( + "{}.{}.{}".format(file_prefix, frame_str, ext) + ) + return expected_files + + def get_files(self, product, camera): + # type: (RenderProduct, str) -> list + """Return list of expected files. + + It will translate render token strings ('', etc.) to + their values. This task is tricky as every renderer deals with this + differently. That's why we expose `get_files` as a method on the + Renderer class so it can be overridden for complex cases. + + Args: + product (RenderProduct): Render product to be used for file + generation. + camera (str): Camera name. + + Returns: + List of files + + """ + return self._generate_file_sequence( + self.layer_data, + force_aov_name=product.productName, + force_ext=product.ext, + force_cameras=[camera] + ) + + def get_renderable_cameras(self): + # type: () -> list + """Get all renderable camera transforms. + + Returns: + list: list of renderable cameras. + + """ + + renderable_cameras = [ + cam for cam in cmds.ls(cameras=True) + if self._get_attr(cam, "renderable") + ] + + # The output produces a sanitized name for using its + # shortest unique path of the transform so we'll return + # at least that unique path. This could include a parent + # name too when two cameras have the same name but are + # in a different hierarchy, e.g. "group1|cam" and "group2|cam" + def get_name(camera): + return cmds.ls(cmds.listRelatives(camera, + parent=True, + fullPath=True))[0] + + return [get_name(cam) for cam in renderable_cameras] + + +class RenderProductsArnold(ARenderProducts): + """Render products for Arnold renderer. + + References: + mtoa.utils.getFileName() + mtoa.utils.ui.common.updateArnoldTargetFilePreview() + + Notes: + - Output Denoising AOVs are not currently included. + - Only Frame/Animation ext: name.#.ext is supported. + - Use Custom extension is not supported. + - and tokens not tested + - With Merge AOVs but in File Name Prefix Arnold + will still NOT merge the aovs. This class correctly resolves + it - but user should be aware. + - File Path Prefix overrides per AOV driver are not implemented + + Attributes: + aiDriverExtension (dict): Arnold AOV driver extension mapping. + Is there a better way? + renderer (str): name of renderer. + + """ + renderer = "arnold" + aiDriverExtension = { + "jpeg": "jpg", + "exr": "exr", + "deepexr": "exr", + "png": "png", + "tiff": "tif", + "mtoa_shaders": "ass", # TODO: research what those last two should be + "maya": "", + } + + def get_renderer_prefix(self): + + prefix = super(RenderProductsArnold, self).get_renderer_prefix() + merge_aovs = self._get_attr("defaultArnoldDriver.mergeAOVs") + if not merge_aovs and "" not in prefix.lower(): + # When Merge AOVs is disabled and token not present + # then Arnold prepends / to the output path. + # todo: It's untested what happens if AOV driver has an + # an explicit override path prefix. + prefix = "/" + prefix + + return prefix + + def _get_aov_render_products(self, aov): + """Return all render products for the AOV""" + + products = list() + aov_name = self._get_attr(aov, "name") + ai_drivers = cmds.listConnections("{}.outputs".format(aov), + source=True, + destination=False, + type="aiAOVDriver") or [] + + for ai_driver in ai_drivers: + # todo: check aiAOVDriver.prefix as it could have + # a custom path prefix set for this driver + + # Skip Drivers set only for GUI + # 0: GUI, 1: Batch, 2: GUI and Batch + output_mode = self._get_attr(ai_driver, "outputMode") + if output_mode == 0: # GUI only + log.warning("%s has Output Mode set to GUI, " + "skipping...", ai_driver) + continue + + ai_translator = self._get_attr(ai_driver, "aiTranslator") + try: + ext = self.aiDriverExtension[ai_translator] + except KeyError: + raise AOVError( + "Unrecognized arnold driver format " + "for AOV - {}".format(aov_name) + ) + + # If aov RGBA is selected, arnold will translate it to `beauty` + name = aov_name + if name == "RGBA": + name = "beauty" + + # Support Arnold light groups for AOVs + # Global AOV: When disabled the main layer is not written: `{pass}` + # All Light Groups: When enabled, a `{pass}_lgroups` file is + # written and is always merged into a single file + # Light Groups List: When set, a product per light group is written + # e.g. {pass}_front, {pass}_rim + global_aov = self._get_attr(aov, "globalAov") + if global_aov: + product = RenderProduct(productName=name, + ext=ext, + aov=aov_name, + driver=ai_driver) + products.append(product) + + all_light_groups = self._get_attr(aov, "lightGroups") + if all_light_groups: + # All light groups is enabled. A single multipart + # Render Product + product = RenderProduct(productName=name + "_lgroups", + ext=ext, + aov=aov_name, + driver=ai_driver, + # Always multichannel output + multipart=True) + products.append(product) + else: + value = self._get_attr(aov, "lightGroupsList") + if not value: + continue + selected_light_groups = value.strip().split() + for light_group in selected_light_groups: + # Render Product per selected light group + aov_light_group_name = "{}_{}".format(name, light_group) + product = RenderProduct(productName=aov_light_group_name, + aov=aov_name, + driver=ai_driver, + ext=ext) + products.append(product) + + return products + + def get_render_products(self): + """Get all AOVs. + + See Also: + :func:`ARenderProducts.get_render_products()` + + Raises: + :class:`AOVError`: If AOV cannot be determined. + + """ + + if not cmds.ls("defaultArnoldRenderOptions", type="aiOptions"): + # this occurs when Render Setting windows was not opened yet. In + # such case there are no Arnold options created so query for AOVs + # will fail. We terminate here as there are no AOVs specified then. + # This state will most probably fail later on some Validator + # anyway. + return [] + + default_ext = self._get_attr("defaultRenderGlobals.imfPluginKey") + beauty_product = RenderProduct(productName="beauty", + ext=default_ext, + driver="defaultArnoldDriver") + + # AOVs > Legacy > Maya Render View > Mode + aovs_enabled = bool( + self._get_attr("defaultArnoldRenderOptions.aovMode") + ) + if not aovs_enabled: + return [beauty_product] + + # Common > File Output > Merge AOVs or + # We don't need to check for Merge AOVs due to overridden + # `get_renderer_prefix()` behavior which forces + has_renderpass_token = ( + "" in self.layer_data.filePrefix.lower() + ) + if not has_renderpass_token: + beauty_product.multipart = True + return [beauty_product] + + # AOVs are set to be rendered separately. We should expect + # token in path. + # handle aovs from references + use_ref_aovs = self.render_instance.data.get( + "useReferencedAovs", False) or False + + aovs = cmds.ls(type="aiAOV") + if not use_ref_aovs: + ref_aovs = cmds.ls(type="aiAOV", referencedNodes=True) + aovs = list(set(aovs) - set(ref_aovs)) + + products = [] + + # Append the AOV products + for aov in aovs: + enabled = self._get_attr(aov, "enabled") + if not enabled: + continue + + # For now stick to the legacy output format. + aov_products = self._get_aov_render_products(aov) + products.extend(aov_products) + + if not any(product.aov == "RGBA" for product in products): + # Append default 'beauty' as this is arnolds default. + # However, it is excluded whenever a RGBA pass is enabled. + # For legibility add the beauty layer as first entry + products.insert(0, beauty_product) + + # TODO: Output Denoising AOVs? + + return products + + +class RenderProductsVray(ARenderProducts): + """Expected files for V-Ray renderer. + + Notes: + - "Disabled" animation incorrectly returns frames in filename + - "Renumber Frames" is not supported + + Reference: + vrayAddRenderElementImpl() in vrayCreateRenderElementsTab.mel + + """ + # todo: detect whether rendering with V-Ray GPU + whether AOV is supported + + renderer = "vray" + + def get_renderer_prefix(self): + # type: () -> str + """Get image prefix for V-Ray. + + This overrides :func:`ARenderProducts.get_renderer_prefix()` as + we must add `` token manually. + + See also: + :func:`ARenderProducts.get_renderer_prefix()` + + """ + prefix = super(RenderProductsVray, self).get_renderer_prefix() + prefix = "{}.".format(prefix) + return prefix + + def _get_layer_data(self): + # type: () -> LayerMetadata + """Override to get vray specific extension.""" + layer_data = super(RenderProductsVray, self)._get_layer_data() + + default_ext = self._get_attr("vraySettings.imageFormatStr") + if default_ext in ["exr (multichannel)", "exr (deep)"]: + default_ext = "exr" + layer_data.defaultExt = default_ext + layer_data.padding = self._get_attr("vraySettings.fileNamePadding") + + return layer_data + + def get_render_products(self): + """Get all AOVs. + + See Also: + :func:`ARenderProducts.get_render_products()` + + """ + if not cmds.ls("vraySettings", type="VRaySettingsNode"): + # this occurs when Render Setting windows was not opened yet. In + # such case there are no VRay options created so query for AOVs + # will fail. We terminate here as there are no AOVs specified then. + # This state will most probably fail later on some Validator + # anyway. + return [] + + image_format_str = self._get_attr("vraySettings.imageFormatStr") + default_ext = image_format_str + if default_ext in {"exr (multichannel)", "exr (deep)"}: + default_ext = "exr" + + products = [] + + # add beauty as default when not disabled + dont_save_rgb = self._get_attr("vraySettings.dontSaveRgbChannel") + if not dont_save_rgb: + products.append(RenderProduct(productName="", ext=default_ext)) + + # separate alpha file + separate_alpha = self._get_attr("vraySettings.separateAlpha") + if separate_alpha: + products.append(RenderProduct(productName="Alpha", + ext=default_ext)) + + if image_format_str == "exr (multichannel)": + # AOVs are merged in m-channel file, only main layer is rendered + self.multipart = True + return products + + # handle aovs from references + use_ref_aovs = self.render_instance.data.get( + "useReferencedAovs", False) or False + + # this will have list of all aovs no matter if they are coming from + # reference or not. + aov_types = ["VRayRenderElement", "VRayRenderElementSet"] + aovs = cmds.ls(type=aov_types) + if not use_ref_aovs: + ref_aovs = cmds.ls(type=aov_types, referencedNodes=True) or [] + aovs = list(set(aovs) - set(ref_aovs)) + + for aov in aovs: + enabled = self._get_attr(aov, "enabled") + if not enabled: + continue + + class_type = self._get_attr(aov + ".vrayClassType") + if class_type == "LightMixElement": + # Special case which doesn't define a name by itself but + # instead seems to output multiple Render Products, + # specifically "Self_Illumination" and "Environment" + product_names = ["Self_Illumination", "Environment"] + for name in product_names: + product = RenderProduct(productName=name, + ext=default_ext, + aov=aov) + products.append(product) + # Continue as we've processed this special case AOV + continue + + aov_name = self._get_vray_aov_name(aov) + product = RenderProduct(productName=aov_name, + ext=default_ext, + aov=aov) + products.append(product) + + return products + + def _get_vray_aov_attr(self, node, prefix): + """Get value for attribute that starts with key in name + + V-Ray AOVs have attribute names that include the type + of AOV in the attribute name, for example: + - vray_filename_rawdiffuse + - vray_filename_velocity + - vray_name_gi + - vray_explicit_name_extratex + + To simplify querying the "vray_filename" or "vray_name" + attributes we just find the first attribute that has + that particular "{prefix}_" in the attribute name. + + Args: + node (str): AOV node name + prefix (str): Prefix of the attribute name. + + Returns: + Value of the attribute if it exists, else None + + """ + attrs = cmds.listAttr(node, string="{}_*".format(prefix)) + if not attrs: + return None + + assert len(attrs) == 1, "Found more than one attribute: %s" % attrs + attr = attrs[0] + + return self._get_attr(node, attr) + + def _get_vray_aov_name(self, node): + """Get AOVs name from Vray. + + Args: + node (str): aov node name. + + Returns: + str: aov name. + + """ + + vray_explicit_name = self._get_vray_aov_attr(node, + "vray_explicit_name") + vray_filename = self._get_vray_aov_attr(node, "vray_filename") + vray_name = self._get_vray_aov_attr(node, "vray_name") + final_name = vray_explicit_name or vray_filename or vray_name or None + + class_type = self._get_attr(node, "vrayClassType") + if not vray_explicit_name: + # Explicit name takes precedence and overrides completely + # otherwise add the connected node names to the special cases + # Any namespace colon ':' gets replaced to underscore '_' + # so we sanitize using `sanitize_camera_name` + def _get_source_name(node, attr): + """Return sanitized name of input connection to attribute""" + plug = "{}.{}".format(node, attr) + connections = cmds.listConnections(plug, + source=True, + destination=False) + if connections: + return self.sanitize_camera_name(connections[0]) + + if class_type == "MaterialSelectElement": + # Name suffix is based on the connected material or set + attrs = [ + "vray_mtllist_mtlselect", + "vray_mtl_mtlselect" + ] + for attribute in attrs: + name = _get_source_name(node, attribute) + if name: + final_name += '_{}'.format(name) + break + else: + log.warning("Material Select Element has no " + "selected materials: %s", node) + + elif class_type == "ExtraTexElement": + # Name suffix is based on the connected textures + extratex_type = self._get_attr(node, "vray_type_extratex") + attr = { + 0: "vray_texture_extratex", + 1: "vray_float_texture_extratex", + 2: "vray_int_texture_extratex", + }.get(extratex_type) + name = _get_source_name(node, attr) + if name: + final_name += '_{}'.format(name) + else: + log.warning("Extratex Element has no incoming texture") + + assert final_name, "Output filename not defined for AOV: %s" % node + + return final_name + + +class RenderProductsRedshift(ARenderProducts): + """Expected files for Redshift renderer. + + Notes: + - `get_files()` only supports rendering with frames, like "animation" + + Attributes: + + unmerged_aovs (list): Name of aovs that are not merged into resulting + exr and we need them specified in Render Products output. + + """ + + renderer = "redshift" + unmerged_aovs = {"Cryptomatte"} + + def get_renderer_prefix(self): + """Get image prefix for Redshift. + + This overrides :func:`ARenderProducts.get_renderer_prefix()` as + we must add `` token manually. + + See also: + :func:`ARenderProducts.get_renderer_prefix()` + + """ + prefix = super(RenderProductsRedshift, self).get_renderer_prefix() + prefix = "{}.".format(prefix) + return prefix + + def get_render_products(self): + """Get all AOVs. + + See Also: + :func:`ARenderProducts.get_render_products()` + + """ + + if not cmds.ls("redshiftOptions", type="RedshiftOptions"): + # this occurs when Render Setting windows was not opened yet. In + # such case there are no Redshift options created so query for AOVs + # will fail. We terminate here as there are no AOVs specified then. + # This state will most probably fail later on some Validator + # anyway. + return [] + + # For Redshift we don't directly return upon forcing multilayer + # due to some AOVs still being written into separate files, + # like Cryptomatte. + # AOVs are merged in multi-channel file + multipart = bool(self._get_attr("redshiftOptions.exrForceMultilayer")) + + # Get Redshift Extension from image format + image_format = self._get_attr("redshiftOptions.imageFormat") # integer + ext = mel.eval("redshiftGetImageExtension(%i)" % image_format) + + use_ref_aovs = self.render_instance.data.get( + "useReferencedAovs", False) or False + + aovs = cmds.ls(type="RedshiftAOV") + if not use_ref_aovs: + ref_aovs = cmds.ls(type="RedshiftAOV", referencedNodes=True) + aovs = list(set(aovs) - set(ref_aovs)) + + products = [] + light_groups_enabled = False + has_beauty_aov = False + for aov in aovs: + enabled = self._get_attr(aov, "enabled") + if not enabled: + continue + + aov_type = self._get_attr(aov, "aovType") + if multipart and aov_type not in self.unmerged_aovs: + continue + + # Any AOVs that still get processed, like Cryptomatte + # by themselves are not multipart files. + aov_multipart = not multipart + + # Redshift skips rendering of masterlayer without AOV suffix + # when a Beauty AOV is rendered. It overrides the main layer. + if aov_type == "Beauty": + has_beauty_aov = True + + aov_name = self._get_attr(aov, "name") + + # Support light Groups + light_groups = [] + if self._get_attr(aov, "supportsLightGroups"): + all_light_groups = self._get_attr(aov, "allLightGroups") + if all_light_groups: + # All light groups is enabled + light_groups = self._get_redshift_light_groups() + else: + value = self._get_attr(aov, "lightGroupList") + # note: string value can return None when never set + if value: + selected_light_groups = value.strip().split() + light_groups = selected_light_groups + + for light_group in light_groups: + aov_light_group_name = "{}_{}".format(aov_name, + light_group) + product = RenderProduct(productName=aov_light_group_name, + aov=aov_name, + ext=ext, + multipart=aov_multipart) + products.append(product) + + if light_groups: + light_groups_enabled = True + + # Redshift AOV Light Select always renders the global AOV + # even when light groups are present so we don't need to + # exclude it when light groups are active + product = RenderProduct(productName=aov_name, + aov=aov_name, + ext=ext, + multipart=aov_multipart) + products.append(product) + + # When a Beauty AOV is added manually, it will be rendered as + # 'Beauty_other' in file name and "standard" beauty will have + # 'Beauty' in its name. When disabled, standard output will be + # without `Beauty`. Except when using light groups. + if light_groups_enabled: + return products + + beauty_name = "Beauty_other" if has_beauty_aov else "" + products.insert(0, + RenderProduct(productName=beauty_name, + ext=ext, + multipart=multipart)) + + return products + + @staticmethod + def _get_redshift_light_groups(): + return sorted(mel.eval("redshiftAllAovLightGroups")) + + +class RenderProductsRenderman(ARenderProducts): + """Expected files for Renderman renderer. + + Warning: + This is very rudimentary and needs more love and testing. + """ + + renderer = "renderman" + + def get_render_products(self): + """Get all AOVs. + + See Also: + :func:`ARenderProducts.get_render_products()` + + """ + products = [] + + default_ext = "exr" + displays = cmds.listConnections("rmanGlobals.displays") + for aov in displays: + enabled = self._get_attr(aov, "enabled") + if not enabled: + continue + + aov_name = str(aov) + if aov_name == "rmanDefaultDisplay": + aov_name = "beauty" + + product = RenderProduct(productName=aov_name, + ext=default_ext) + products.append(product) + + return products + + def get_files(self, product, camera): + """Get expected files. + + In renderman we hack it with prepending path. This path would + normally be translated from `rmanGlobals.imageOutputDir`. We skip + this and hardcode prepend path we expect. There is no place for user + to mess around with this settings anyway and it is enforced in + render settings validator. + """ + files = super(RenderProductsRenderman, self).get_files(product, camera) + + layer_data = self.layer_data + new_files = [] + for file in files: + new_file = "{}/{}/{}".format( + layer_data["sceneName"], layer_data["layerName"], file + ) + new_files.append(new_file) + + return new_files + + +class AOVError(Exception): + """Custom exception for determining AOVs.""" + + +class UnsupportedRendererException(Exception): + """Custom exception. + + Raised when requesting data from unsupported renderer. + """ diff --git a/openpype/hosts/maya/api/lib_rendersetup.py b/openpype/hosts/maya/api/lib_rendersetup.py new file mode 100644 index 0000000000..0736febe9c --- /dev/null +++ b/openpype/hosts/maya/api/lib_rendersetup.py @@ -0,0 +1,343 @@ +# -*- coding: utf-8 -*- +"""Library for handling Render Setup in Maya.""" +from maya import cmds +import maya.api.OpenMaya as om +import logging + +import maya.app.renderSetup.model.utils as utils +from maya.app.renderSetup.model import ( + renderSetup +) +from maya.app.renderSetup.model.override import ( + AbsOverride, + RelOverride, + UniqueOverride +) + +ExactMatch = 0 +ParentMatch = 1 +ChildMatch = 2 + +DefaultRenderLayer = "defaultRenderLayer" + +log = logging.getLogger(__name__) + + +def get_rendersetup_layer(layer): + """Return render setup layer name. + + This also converts names from legacy renderLayer node name to render setup + name. + + Note: `defaultRenderLayer` is not a renderSetupLayer node but it is however + the valid layer name for Render Setup - so we return that as is. + + Example: + >>> for legacy_layer in cmds.ls(type="renderLayer"): + >>> layer = get_rendersetup_layer(legacy_layer) + + Returns: + str or None: Returns renderSetupLayer node name if `layer` is a valid + layer name in legacy renderlayers or render setup layers. + Returns None if the layer can't be found or Render Setup is + currently disabled. + + + """ + if layer == DefaultRenderLayer: + # defaultRenderLayer doesn't have a `renderSetupLayer` + return layer + + if not cmds.mayaHasRenderSetup(): + return None + + if not cmds.objExists(layer): + return None + + if cmds.nodeType(layer) == "renderSetupLayer": + return layer + + # By default Render Setup renames the legacy renderlayer + # to `rs_` but lets not rely on that as the + # layer node can be renamed manually + connections = cmds.listConnections(layer + ".message", + type="renderSetupLayer", + exactType=True, + source=False, + destination=True, + plugs=True) or [] + return next((conn.split(".", 1)[0] for conn in connections + if conn.endswith(".legacyRenderLayer")), None) + + +def get_attr_in_layer(node_attr, layer): + """Return attribute value in Render Setup layer. + + This will only work for attributes which can be + retrieved with `maya.cmds.getAttr` and for which + Relative and Absolute overrides are applicable. + + Examples: + >>> get_attr_in_layer("defaultResolution.width", layer="layer1") + >>> get_attr_in_layer("defaultRenderGlobals.startFrame", layer="layer") + >>> get_attr_in_layer("transform.translate", layer="layer3") + + Args: + attr (str): attribute name as 'node.attribute' + layer (str): layer name + + Returns: + object: attribute value in layer + + """ + + # Delay pymel import to here because it's slow to load + import pymel.core as pm + + def _layer_needs_update(layer): + """Return whether layer needs updating.""" + # Use `getattr` as e.g. DefaultRenderLayer does not have the attribute + return getattr(layer, "needsMembershipUpdate", False) or \ + getattr(layer, "needsApplyUpdate", False) + + def get_default_layer_value(node_attr_): + """Return attribute value in defaultRenderLayer""" + inputs = cmds.listConnections(node_attr_, + source=True, + destination=False, + # We want to skip conversion nodes since + # an override to `endFrame` could have + # a `unitToTimeConversion` node + # in-between + skipConversionNodes=True, + type="applyOverride") or [] + if inputs: + _override = inputs[0] + history_overrides = cmds.ls(cmds.listHistory(_override, + pruneDagObjects=True), + type="applyOverride") + node = history_overrides[-1] if history_overrides else _override + node_attr_ = node + ".original" + + return pm.getAttr(node_attr_, asString=True) + + layer = get_rendersetup_layer(layer) + rs = renderSetup.instance() + current_layer = rs.getVisibleRenderLayer() + if current_layer.name() == layer: + + # Ensure layer is up-to-date + if _layer_needs_update(current_layer): + try: + rs.switchToLayer(current_layer) + except RuntimeError: + # Some cases can cause errors on switching + # the first time with Render Setup layers + # e.g. different overrides to compounds + # and its children plugs. So we just force + # it another time. If it then still fails + # we will let it error out. + rs.switchToLayer(current_layer) + + return pm.getAttr(node_attr, asString=True) + + overrides = get_attr_overrides(node_attr, layer) + default_layer_value = get_default_layer_value(node_attr) + if not overrides: + return default_layer_value + + value = default_layer_value + for match, layer_override, index in overrides: + if isinstance(layer_override, AbsOverride): + # Absolute override + value = pm.getAttr(layer_override.name() + ".attrValue") + if match == ExactMatch: + value = value + if match == ParentMatch: + value = value[index] + if match == ChildMatch: + value[index] = value + + elif isinstance(layer_override, RelOverride): + # Relative override + # Value = Original * Multiply + Offset + multiply = pm.getAttr(layer_override.name() + ".multiply") + offset = pm.getAttr(layer_override.name() + ".offset") + + if match == ExactMatch: + value = value * multiply + offset + if match == ParentMatch: + value = value * multiply[index] + offset[index] + if match == ChildMatch: + value[index] = value[index] * multiply + offset + + else: + raise TypeError("Unsupported override: %s" % layer_override) + + return value + + +def get_attr_overrides(node_attr, layer, + skip_disabled=True, + skip_local_render=True, + stop_at_absolute_override=True): + """Return all Overrides applicable to the attribute. + + Overrides are returned as a 3-tuple: + (Match, Override, Index) + + Match: + This is any of ExactMatch, ParentMatch, ChildMatch + and defines whether the override is exactly on the + plug, on the parent or on a child plug. + + Override: + This is the RenderSetup Override instance. + + Index: + This is the Plug index under the parent or for + the child that matches. The ExactMatch index will + always be None. For ParentMatch the index is which + index the plug is under the parent plug. For ChildMatch + the index is which child index matches the plug. + + Args: + node_attr (str): attribute name as 'node.attribute' + layer (str): layer name + skip_disabled (bool): exclude disabled overrides + skip_local_render (bool): exclude overrides marked + as local render. + stop_at_absolute_override: exclude overrides prior + to the last absolute override as they have + no influence on the resulting value. + + Returns: + list: Ordered Overrides in order of strength + + """ + + def get_mplug_children(plug): + """Return children MPlugs of compound MPlug""" + children = [] + if plug.isCompound: + for i in range(plug.numChildren()): + children.append(plug.child(i)) + return children + + def get_mplug_names(mplug): + """Return long and short name of MPlug""" + long_name = mplug.partialName(useLongNames=True) + short_name = mplug.partialName(useLongNames=False) + return {long_name, short_name} + + def iter_override_targets(_override): + try: + for target in _override._targets(): + yield target + except AssertionError: + # Workaround: There is a bug where the private `_targets()` method + # fails on some attribute plugs. For example overrides + # to the defaultRenderGlobals.endFrame + # (Tested in Maya 2020.2) + log.debug("Workaround for %s" % _override) + from maya.app.renderSetup.common.utils import findPlug + + attr = _override.attributeName() + if isinstance(_override, UniqueOverride): + node = _override.targetNodeName() + yield findPlug(node, attr) + else: + nodes = _override.parent().selector().nodes() + for node in nodes: + if cmds.attributeQuery(attr, node=node, exists=True): + yield findPlug(node, attr) + + # Get the MPlug for the node.attr + sel = om.MSelectionList() + sel.add(node_attr) + plug = sel.getPlug(0) + + layer = get_rendersetup_layer(layer) + if layer == DefaultRenderLayer: + # DefaultRenderLayer will never have overrides + # since it's the default layer + return [] + + rs_layer = renderSetup.instance().getRenderLayer(layer) + if rs_layer is None: + # Renderlayer does not exist + return + + # Get any parent or children plugs as we also + # want to include them in the attribute match + # for overrides + parent = plug.parent() if plug.isChild else None + parent_index = None + if parent: + parent_index = get_mplug_children(parent).index(plug) + + children = get_mplug_children(plug) + + # Create lookup for the attribute by both long + # and short names + attr_names = get_mplug_names(plug) + for child in children: + attr_names.update(get_mplug_names(child)) + if parent: + attr_names.update(get_mplug_names(parent)) + + # Get all overrides of the layer + # And find those that are relevant to the attribute + plug_overrides = [] + + # Iterate over the overrides in reverse so we get the last + # overrides first and can "break" whenever an absolute + # override is reached + layer_overrides = list(utils.getOverridesRecursive(rs_layer)) + for layer_override in reversed(layer_overrides): + + if skip_disabled and not layer_override.isEnabled(): + # Ignore disabled overrides + continue + + if skip_local_render and layer_override.isLocalRender(): + continue + + # The targets list can be very large so we'll do + # a quick filter by attribute name to detect whether + # it matches the attribute name, or its parent or child + if layer_override.attributeName() not in attr_names: + continue + + override_match = None + for override_plug in iter_override_targets(layer_override): + + override_match = None + if plug == override_plug: + override_match = (ExactMatch, layer_override, None) + + elif parent and override_plug == parent: + override_match = (ParentMatch, layer_override, parent_index) + + elif children and override_plug in children: + child_index = children.index(override_plug) + override_match = (ChildMatch, layer_override, child_index) + + if override_match: + plug_overrides.append(override_match) + break + + if ( + override_match and + stop_at_absolute_override and + isinstance(layer_override, AbsOverride) and + # When the override is only on a child plug then it doesn't + # override the entire value so we not stop at this override + not override_match[0] == ChildMatch + ): + # If override is absolute override, then BREAK out + # of parent loop we don't need to look any further as + # this is the absolute override + break + + return reversed(plug_overrides) diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index 0dced48868..ad225dcd28 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -16,12 +16,9 @@ log = logging.getLogger(__name__) def _get_menu(menu_name=None): """Return the menu instance if it currently exists in Maya""" - - project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) - _menu = project_settings["maya"]["scriptsmenu"]["name"] - if menu_name is None: - menu_name = _menu + menu_name = pipeline._menu + widgets = dict(( w.objectName(), w) for w in QtWidgets.QApplication.allWidgets()) menu = widgets.get(menu_name) @@ -58,11 +55,64 @@ def deferred(): parent=pipeline._parent ) + # Find the pipeline menu + top_menu = _get_menu() + + # Try to find workfile tool action in the menu + workfile_action = None + for action in top_menu.actions(): + if action.text() == "Work Files": + workfile_action = action + break + + # Add at the top of menu if "Work Files" action was not found + after_action = "" + if workfile_action: + # Use action's object name for `insertAfter` argument + after_action = workfile_action.objectName() + + # Insert action to menu + cmds.menuItem( + "Work Files", + parent=pipeline._menu, + command=launch_workfiles_app, + insertAfter=after_action + ) + + # Remove replaced action + if workfile_action: + top_menu.removeAction(workfile_action) + + def remove_project_manager(): + top_menu = _get_menu() + + # Try to find "System" menu action in the menu + system_menu = None + for action in top_menu.actions(): + if action.text() == "System": + system_menu = action + break + + if system_menu is None: + return + + # Try to find "Project manager" action in "System" menu + project_manager_action = None + for action in system_menu.menu().children(): + if hasattr(action, "text") and action.text() == "Project Manager": + project_manager_action = action + break + + # Remove "Project manager" action if was found + if project_manager_action is not None: + system_menu.menu().removeAction(project_manager_action) + log.info("Attempting to install scripts menu ...") add_build_workfiles_item() add_look_assigner_item() modify_workfiles() + remove_project_manager() try: import scriptsmenu.launchformaya as launchformaya @@ -110,7 +160,6 @@ def install(): log.info("Skipping openpype.menu initialization in batch mode..") return - uninstall() # Allow time for uninstallation to finish. cmds.evalDeferred(deferred) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 257908c768..121f7a08a7 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -99,14 +99,24 @@ class ReferenceLoader(api.Loader): nodes = self[:] if not nodes: return - - loaded_containers.append(containerise( - name=name, - namespace=namespace, - nodes=nodes, - context=context, - loader=self.__class__.__name__ - )) + # FIXME: there is probably better way to do this for looks. + if "look" in self.families: + loaded_containers.append(containerise( + name=name, + namespace=namespace, + nodes=nodes, + context=context, + loader=self.__class__.__name__ + )) + else: + ref_node = self._get_reference_node(nodes) + loaded_containers.append(containerise( + name=name, + namespace=namespace, + nodes=[ref_node], + context=context, + loader=self.__class__.__name__ + )) c += 1 namespace = None @@ -235,9 +245,6 @@ class ReferenceLoader(api.Loader): self.log.info("Setting %s.verticesOnlySet to False", node) cmds.setAttr("{}.verticesOnlySet".format(node), False) - # Add new nodes of the reference to the container - cmds.sets(content, forceElement=node) - # Remove any placeHolderList attribute entries from the set that # are remaining from nodes being removed from the referenced file. members = cmds.sets(node, query=True) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index cbca091365..4fd4b9d986 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -4,6 +4,8 @@ import os import json import appdirs import requests +import six +import sys from maya import cmds import maya.app.renderSetup.model.renderSetup as renderSetup @@ -12,7 +14,13 @@ from openpype.hosts.maya.api import ( lib, plugin ) -from openpype.api import (get_system_settings, get_asset) +from openpype.api import ( + get_system_settings, + get_project_settings, + get_asset) +from openpype.modules import ModulesManager + +from avalon.api import Session class CreateRender(plugin.Creator): @@ -83,6 +91,32 @@ class CreateRender(plugin.Creator): def __init__(self, *args, **kwargs): """Constructor.""" super(CreateRender, self).__init__(*args, **kwargs) + deadline_settings = get_system_settings()["modules"]["deadline"] + if not deadline_settings["enabled"]: + self.deadline_servers = {} + return + project_settings = get_project_settings(Session["AVALON_PROJECT"]) + try: + default_servers = deadline_settings["deadline_urls"] + project_servers = ( + project_settings["deadline"] + ["deadline_servers"] + ) + self.deadline_servers = { + k: default_servers[k] + for k in project_servers + if k in default_servers + } + + if not self.deadline_servers: + self.deadline_servers = default_servers + + except AttributeError: + # Handle situation were we had only one url for deadline. + manager = ModulesManager() + deadline_module = manager.modules_by_name["deadline"] + # get default deadline webservice url from deadline module + self.deadline_servers = deadline_module.deadline_urls def process(self): """Entry point.""" @@ -94,10 +128,10 @@ class CreateRender(plugin.Creator): use_selection = self.options.get("useSelection") with lib.undo_chunk(): self._create_render_settings() - instance = super(CreateRender, self).process() + self.instance = super(CreateRender, self).process() # create namespace with instance index = 1 - namespace_name = "_{}".format(str(instance)) + namespace_name = "_{}".format(str(self.instance)) try: cmds.namespace(rm=namespace_name) except RuntimeError: @@ -105,12 +139,20 @@ class CreateRender(plugin.Creator): pass while cmds.namespace(exists=namespace_name): - namespace_name = "_{}{}".format(str(instance), index) + namespace_name = "_{}{}".format(str(self.instance), index) index += 1 namespace = cmds.namespace(add=namespace_name) - cmds.setAttr("{}.machineList".format(instance), lock=True) + # add Deadline server selection list + if self.deadline_servers: + cmds.scriptJob( + attributeChange=[ + "{}.deadlineServers".format(self.instance), + self._deadline_webservice_changed + ]) + + cmds.setAttr("{}.machineList".format(self.instance), lock=True) self._rs = renderSetup.instance() layers = self._rs.getRenderLayers() if use_selection: @@ -122,7 +164,7 @@ class CreateRender(plugin.Creator): render_set = cmds.sets( n="{}:{}".format(namespace, layer.name())) sets.append(render_set) - cmds.sets(sets, forceElement=instance) + cmds.sets(sets, forceElement=self.instance) # if no render layers are present, create default one with # asterisk selector @@ -138,62 +180,61 @@ class CreateRender(plugin.Creator): renderer = 'renderman' self._set_default_renderer_settings(renderer) + return self.instance + + def _deadline_webservice_changed(self): + """Refresh Deadline server dependent options.""" + # get selected server + from maya import cmds + webservice = self.deadline_servers[ + self.server_aliases[ + cmds.getAttr("{}.deadlineServers".format(self.instance)) + ] + ] + pools = self._get_deadline_pools(webservice) + cmds.deleteAttr("{}.primaryPool".format(self.instance)) + cmds.deleteAttr("{}.secondaryPool".format(self.instance)) + cmds.addAttr(self.instance, longName="primaryPool", + attributeType="enum", + enumName=":".join(pools)) + cmds.addAttr(self.instance, longName="secondaryPool", + attributeType="enum", + enumName=":".join(["-"] + pools)) + + def _get_deadline_pools(self, webservice): + # type: (str) -> list + """Get pools from Deadline. + Args: + webservice (str): Server url. + Returns: + list: Pools. + Throws: + RuntimeError: If deadline webservice is unreachable. + + """ + argument = "{}/api/pools?NamesOnly=true".format(webservice) + try: + response = self._requests_get(argument) + except requests.exceptions.ConnectionError as exc: + msg = 'Cannot connect to deadline web service' + self.log.error(msg) + six.reraise( + RuntimeError, + RuntimeError('{} - {}'.format(msg, exc)), + sys.exc_info()[2]) + if not response.ok: + self.log.warning("No pools retrieved") + return [] + + return response.json() def _create_render_settings(self): + """Create instance settings.""" # get pools - pools = [] - - system_settings = get_system_settings()["modules"] - - deadline_enabled = system_settings["deadline"]["enabled"] - muster_enabled = system_settings["muster"]["enabled"] - deadline_url = system_settings["deadline"]["DEADLINE_REST_URL"] - muster_url = system_settings["muster"]["MUSTER_REST_URL"] - - if deadline_enabled and muster_enabled: - self.log.error( - "Both Deadline and Muster are enabled. " "Cannot support both." - ) - raise RuntimeError("Both Deadline and Muster are enabled") - - if deadline_enabled: - argument = "{}/api/pools?NamesOnly=true".format(deadline_url) - try: - response = self._requests_get(argument) - except requests.exceptions.ConnectionError as e: - msg = 'Cannot connect to deadline web service' - self.log.error(msg) - raise RuntimeError('{} - {}'.format(msg, e)) - if not response.ok: - self.log.warning("No pools retrieved") - else: - pools = response.json() - self.data["primaryPool"] = pools - # We add a string "-" to allow the user to not - # set any secondary pools - self.data["secondaryPool"] = ["-"] + pools - - if muster_enabled: - self.log.info(">>> Loading Muster credentials ...") - self._load_credentials() - self.log.info(">>> Getting pools ...") - try: - pools = self._get_muster_pools() - except requests.exceptions.HTTPError as e: - if e.startswith("401"): - self.log.warning("access token expired") - self._show_login() - raise RuntimeError("Access token expired") - except requests.exceptions.ConnectionError: - self.log.error("Cannot connect to Muster API endpoint.") - raise RuntimeError("Cannot connect to {}".format(muster_url)) - pool_names = [] - for pool in pools: - self.log.info(" - pool: {}".format(pool["name"])) - pool_names.append(pool["name"]) - - self.data["primaryPool"] = pool_names + pool_names = [] + self.server_aliases = self.deadline_servers.keys() + self.data["deadlineServers"] = self.server_aliases self.data["suspendPublishJob"] = False self.data["review"] = True self.data["extendFrames"] = False @@ -212,6 +253,54 @@ class CreateRender(plugin.Creator): # Disable for now as this feature is not working yet # self.data["assScene"] = False + system_settings = get_system_settings()["modules"] + + deadline_enabled = system_settings["deadline"]["enabled"] + muster_enabled = system_settings["muster"]["enabled"] + muster_url = system_settings["muster"]["MUSTER_REST_URL"] + + if deadline_enabled and muster_enabled: + self.log.error( + "Both Deadline and Muster are enabled. " "Cannot support both." + ) + raise RuntimeError("Both Deadline and Muster are enabled") + + if deadline_enabled: + # if default server is not between selected, use first one for + # initial list of pools. + try: + deadline_url = self.deadline_servers["default"] + except KeyError: + deadline_url = [ + self.deadline_servers[k] + for k in self.deadline_servers.keys() + ][0] + + pool_names = self._get_deadline_pools(deadline_url) + + if muster_enabled: + self.log.info(">>> Loading Muster credentials ...") + self._load_credentials() + self.log.info(">>> Getting pools ...") + pools = [] + try: + pools = self._get_muster_pools() + except requests.exceptions.HTTPError as e: + if e.startswith("401"): + self.log.warning("access token expired") + self._show_login() + raise RuntimeError("Access token expired") + except requests.exceptions.ConnectionError: + self.log.error("Cannot connect to Muster API endpoint.") + raise RuntimeError("Cannot connect to {}".format(muster_url)) + for pool in pools: + self.log.info(" - pool: {}".format(pool["name"])) + pool_names.append(pool["name"]) + + self.data["primaryPool"] = pool_names + # We add a string "-" to allow the user to not + # set any secondary pools + self.data["secondaryPool"] = ["-"] + pool_names self.options = {"useSelection": False} # Force no content def _load_credentials(self): @@ -293,9 +382,7 @@ class CreateRender(plugin.Creator): """ if "verify" not in kwargs: - kwargs["verify"] = ( - False if os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) else True - ) # noqa + kwargs["verify"] = not os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) return requests.post(*args, **kwargs) def _requests_get(self, *args, **kwargs): @@ -312,9 +399,7 @@ class CreateRender(plugin.Creator): """ if "verify" not in kwargs: - kwargs["verify"] = ( - False if os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) else True - ) # noqa + kwargs["verify"] = not os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) return requests.get(*args, **kwargs) def _set_default_renderer_settings(self, renderer): @@ -332,14 +417,10 @@ class CreateRender(plugin.Creator): if renderer == "arnold": # set format to exr + cmds.setAttr( "defaultArnoldDriver.ai_translator", "exr", type="string") - # enable animation - cmds.setAttr("defaultRenderGlobals.outFormatControl", 0) - cmds.setAttr("defaultRenderGlobals.animation", 1) - cmds.setAttr("defaultRenderGlobals.putFrameBeforeExt", 1) - cmds.setAttr("defaultRenderGlobals.extensionPadding", 4) - + self._set_global_output_settings() # resolution cmds.setAttr( "defaultResolution.width", @@ -349,43 +430,12 @@ class CreateRender(plugin.Creator): asset["data"].get("resolutionHeight")) if renderer == "vray": - vray_settings = cmds.ls(type="VRaySettingsNode") - if not vray_settings: - node = cmds.createNode("VRaySettingsNode") - else: - node = vray_settings[0] - - # set underscore as element separator instead of default `.` - cmds.setAttr( - "{}.fileNameRenderElementSeparator".format( - node), - "_" - ) - # set format to exr - cmds.setAttr( - "{}.imageFormatStr".format(node), 5) - - # animType - cmds.setAttr( - "{}.animType".format(node), 1) - - # resolution - cmds.setAttr( - "{}.width".format(node), - asset["data"].get("resolutionWidth")) - cmds.setAttr( - "{}.height".format(node), - asset["data"].get("resolutionHeight")) - + self._set_vray_settings(asset) if renderer == "redshift": - redshift_settings = cmds.ls(type="RedshiftOptions") - if not redshift_settings: - node = cmds.createNode("RedshiftOptions") - else: - node = redshift_settings[0] + _ = self._set_renderer_option( + "RedshiftOptions", "{}.imageFormat", 1 + ) - # set exr - cmds.setAttr("{}.imageFormat".format(node), 1) # resolution cmds.setAttr( "defaultResolution.width", @@ -394,8 +444,56 @@ class CreateRender(plugin.Creator): "defaultResolution.height", asset["data"].get("resolutionHeight")) - # enable animation - cmds.setAttr("defaultRenderGlobals.outFormatControl", 0) - cmds.setAttr("defaultRenderGlobals.animation", 1) - cmds.setAttr("defaultRenderGlobals.putFrameBeforeExt", 1) - cmds.setAttr("defaultRenderGlobals.extensionPadding", 4) + self._set_global_output_settings() + + @staticmethod + def _set_renderer_option(renderer_node, arg=None, value=None): + # type: (str, str, str) -> str + """Set option on renderer node. + + If renderer settings node doesn't exists, it is created first. + + Args: + renderer_node (str): Renderer name. + arg (str, optional): Argument name. + value (str, optional): Argument value. + + Returns: + str: Renderer settings node. + + """ + settings = cmds.ls(type=renderer_node) + result = settings[0] if settings else cmds.createNode(renderer_node) + cmds.setAttr(arg.format(result), value) + return result + + def _set_vray_settings(self, asset): + # type: (dict) -> None + """Sets important settings for Vray.""" + node = self._set_renderer_option( + "VRaySettingsNode", "{}.fileNameRenderElementSeparator", "_" + ) + + # set format to exr + cmds.setAttr( + "{}.imageFormatStr".format(node), 5) + + # animType + cmds.setAttr( + "{}.animType".format(node), 1) + + # resolution + cmds.setAttr( + "{}.width".format(node), + asset["data"].get("resolutionWidth")) + cmds.setAttr( + "{}.height".format(node), + asset["data"].get("resolutionHeight")) + + @staticmethod + def _set_global_output_settings(): + # enable animation + cmds.setAttr("defaultRenderGlobals.outFormatControl", 0) + cmds.setAttr("defaultRenderGlobals.animation", 1) + cmds.setAttr("defaultRenderGlobals.putFrameBeforeExt", 1) + cmds.setAttr("defaultRenderGlobals.extensionPadding", 4) diff --git a/openpype/hosts/maya/plugins/create/create_xgen.py b/openpype/hosts/maya/plugins/create/create_xgen.py new file mode 100644 index 0000000000..3953972952 --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_xgen.py @@ -0,0 +1,11 @@ +from openpype.hosts.maya.api import plugin + + +class CreateXgen(plugin.Creator): + """Xgen interactive export""" + + name = "xgen" + label = "Xgen Interactive" + family = "xgen" + icon = "pagelines" + defaults = ['Main'] diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 96269f2771..d5952ed267 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -17,7 +17,8 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): "layout", "camera", "rig", - "camerarig"] + "camerarig", + "xgen"] representations = ["ma", "abc", "fbx", "mb"] label = "Reference" diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index 647a46e240..5049647ff9 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -49,7 +49,7 @@ import maya.app.renderSetup.model.renderSetup as renderSetup import pyblish.api from avalon import maya, api -from openpype.hosts.maya.api.expected_files import ExpectedFiles +from openpype.hosts.maya.api.lib_renderproducts import get as get_layer_render_products # noqa: E501 from openpype.hosts.maya.api import lib @@ -64,6 +64,8 @@ class CollectMayaRender(pyblish.api.ContextPlugin): def process(self, context): """Entry point to collector.""" render_instance = None + deadline_url = None + for instance in context: if "rendering" in instance.data["families"]: render_instance = instance @@ -86,6 +88,15 @@ class CollectMayaRender(pyblish.api.ContextPlugin): asset = api.Session["AVALON_ASSET"] workspace = context.data["workspaceDir"] + deadline_settings = ( + context.data + ["system_settings"] + ["modules"] + ["deadline"] + ) + + if deadline_settings["enabled"]: + deadline_url = render_instance.data.get("deadlineUrl") self._rs = renderSetup.instance() current_layer = self._rs.getVisibleRenderLayer() maya_render_layers = { @@ -157,10 +168,21 @@ class CollectMayaRender(pyblish.api.ContextPlugin): # return all expected files for all cameras and aovs in given # frame range - ef = ExpectedFiles(render_instance) - exp_files = ef.get(renderer, layer_name) - self.log.info("multipart: {}".format(ef.multipart)) + layer_render_products = get_layer_render_products( + layer_name, render_instance) + render_products = layer_render_products.layer_data.products + assert render_products, "no render products generated" + exp_files = [] + for product in render_products: + for camera in layer_render_products.layer_data.cameras: + exp_files.append( + {product.productName: layer_render_products.get_files( + product, camera)}) + + self.log.info("multipart: {}".format( + layer_render_products.multipart)) assert exp_files, "no file names were generated, this is bug" + self.log.info(exp_files) # if we want to attach render to subset, check if we have AOV's # in expectedFiles. If so, raise error as we cannot attach AOV @@ -175,24 +197,15 @@ class CollectMayaRender(pyblish.api.ContextPlugin): full_exp_files = [] aov_dict = {} - # we either get AOVs or just list of files. List of files can - # mean two things - there are no AOVs enabled or multipass EXR - # is produced. In either case we treat those as `beauty`. - if isinstance(exp_files[0], dict): - for aov, files in exp_files[0].items(): - full_paths = [] - for e in files: - full_path = os.path.join(workspace, "renders", e) - full_path = full_path.replace("\\", "/") - full_paths.append(full_path) - aov_dict[aov] = full_paths - else: + # replace relative paths with absolute. Render products are + # returned as list of dictionaries. + for aov in exp_files: full_paths = [] - for e in exp_files: - full_path = os.path.join(workspace, "renders", e) + for file in aov[aov.keys()[0]]: + full_path = os.path.join(workspace, "renders", file) full_path = full_path.replace("\\", "/") full_paths.append(full_path) - aov_dict["beauty"] = full_paths + aov_dict[aov.keys()[0]] = full_paths frame_start_render = int(self.get_render_attribute( "startFrame", layer=layer_name)) @@ -224,7 +237,7 @@ class CollectMayaRender(pyblish.api.ContextPlugin): "subset": expected_layer_name, "attachTo": attach_to, "setMembers": layer_name, - "multipartExr": ef.multipart, + "multipartExr": layer_render_products.multipart, "review": render_instance.data.get("review") or False, "publish": True, @@ -263,6 +276,9 @@ class CollectMayaRender(pyblish.api.ContextPlugin): "vrayUseReferencedAovs") or False } + if deadline_url: + data["deadlineUrl"] = deadline_url + if self.sync_workfile_version: data["version"] = context.data["version"] @@ -306,10 +322,6 @@ class CollectMayaRender(pyblish.api.ContextPlugin): instance.data.update(data) self.log.debug("data: {}".format(json.dumps(data, indent=4))) - # Restore current layer. - self.log.info("Restoring to {}".format(current_layer.name())) - self._rs.switchToLayer(current_layer) - def parse_options(self, render_globals): """Get all overrides with a value, skip those without. @@ -392,11 +404,13 @@ class CollectMayaRender(pyblish.api.ContextPlugin): rset = self.maya_layers[layer].renderSettingsCollectionInstance() return rset.getOverrides() - def get_render_attribute(self, attr, layer): + @staticmethod + def get_render_attribute(attr, layer): """Get attribute from render options. Args: - attr (str): name of attribute to be looked up. + attr (str): name of attribute to be looked up + layer (str): name of render layer Returns: Attribute value diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py index c85bc0387d..3c2b70900d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py @@ -19,7 +19,8 @@ class ExtractMayaSceneRaw(openpype.api.Extractor): families = ["mayaAscii", "setdress", "layout", - "camerarig"] + "camerarig", + "xgen"] scene_type = "ma" def process(self, instance): diff --git a/openpype/hosts/maya/plugins/publish/extract_xgen_cache.py b/openpype/hosts/maya/plugins/publish/extract_xgen_cache.py new file mode 100644 index 0000000000..d69911c404 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_xgen_cache.py @@ -0,0 +1,61 @@ +import os + +from maya import cmds + +import avalon.maya +import openpype.api + + +class ExtractXgenCache(openpype.api.Extractor): + """Produce an alembic of just xgen interactive groom + + """ + + label = "Extract Xgen ABC Cache" + hosts = ["maya"] + families = ["xgen"] + optional = True + + def process(self, instance): + + # Collect the out set nodes + out_descriptions = [node for node in instance + if cmds.nodeType(node) == "xgmSplineDescription"] + + start = 1 + end = 1 + + self.log.info("Extracting Xgen Cache..") + dirname = self.staging_dir(instance) + + parent_dir = self.staging_dir(instance) + filename = "{name}.abc".format(**instance.data) + path = os.path.join(parent_dir, filename) + + with avalon.maya.suspended_refresh(): + with avalon.maya.maintained_selection(): + command = ( + '-file ' + + path + + ' -df "ogawa" -fr ' + + str(start) + + ' ' + + str(end) + + ' -step 1 -mxf -wfw' + ) + for desc in out_descriptions: + command += (" -obj " + desc) + cmds.xgmSplineCache(export=True, j=command) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'abc', + 'ext': 'abc', + 'files': filename, + "stagingDir": dirname, + } + instance.data["representations"].append(representation) + + self.log.info("Extracted {} to {}".format(instance, dirname)) diff --git a/openpype/hosts/nuke/plugins/load/load_mov.py b/openpype/hosts/nuke/plugins/load/load_mov.py index d84c3d4c71..f7523d0a6e 100644 --- a/openpype/hosts/nuke/plugins/load/load_mov.py +++ b/openpype/hosts/nuke/plugins/load/load_mov.py @@ -259,7 +259,8 @@ class LoadMov(api.Loader): read_node["last"].setValue(last) read_node['frame_mode'].setValue("start at") - if int(self.first_frame) == int(read_node['frame'].value()): + if int(float(self.first_frame)) == int( + float(read_node['frame'].value())): # start at workfile start read_node['frame'].setValue(str(self.first_frame)) else: 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 5611591b56..b0d3ec6241 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py @@ -51,7 +51,6 @@ class ExtractReviewDataLut(openpype.api.Extractor): if "render.farm" in families: instance.data["families"].remove("review") - instance.data["families"].remove("ftrack") self.log.debug( "_ lutPath: {}".format(instance.data["lutPath"])) 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 5032e602a2..cea7d86c26 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -45,7 +45,6 @@ class ExtractReviewDataMov(openpype.api.Extractor): if "render.farm" in families: instance.data["families"].remove("review") - instance.data["families"].remove("ftrack") data = exporter.generate_mov(farm=True) self.log.debug( diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py index 3a9a7a3445..45c6a264dd 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py @@ -2,7 +2,7 @@ import os import opentimelineio as otio import pyblish.api from openpype import lib as plib - +from copy import deepcopy class CollectInstances(pyblish.api.InstancePlugin): """Collect instances from editorial's OTIO sequence""" @@ -186,8 +186,8 @@ class CollectInstances(pyblish.api.InstancePlugin): properities.pop("version") # adding Review-able instance - subset_instance_data = instance_data.copy() - subset_instance_data.update(properities) + subset_instance_data = deepcopy(instance_data) + subset_instance_data.update(deepcopy(properities)) subset_instance_data.update({ # unique attributes "name": f"{name}_{subset}", diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py index d70a0a75b8..596a8ccfd2 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py @@ -270,6 +270,7 @@ class CollectTextures(pyblish.api.ContextPlugin): # store origin if family == 'workfile': families = self.workfile_families + families.append("texture_batch_workfile") new_instance.data["source"] = "standalone publisher" else: diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py index af200b59e0..d592a4a059 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py @@ -8,7 +8,7 @@ class ValidateTextureBatch(pyblish.api.InstancePlugin): label = "Validate Texture Presence" hosts = ["standalonepublisher"] order = openpype.api.ValidateContentsOrder - families = ["workfile"] + families = ["texture_batch_workfile"] optional = False def process(self, instance): diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py index 92f930c3fc..f210be3631 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py @@ -8,7 +8,7 @@ class ValidateTextureBatchNaming(pyblish.api.InstancePlugin): label = "Validate Texture Batch Naming" hosts = ["standalonepublisher"] order = openpype.api.ValidateContentsOrder - families = ["workfile", "textures"] + families = ["texture_batch_workfile", "textures"] optional = False def process(self, instance): diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py index aa3aad71db..25bb5aea4a 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py @@ -11,7 +11,7 @@ class ValidateTextureBatchWorkfiles(pyblish.api.InstancePlugin): label = "Validate Texture Workfile Has Resources" hosts = ["standalonepublisher"] order = openpype.api.ValidateContentsOrder - families = ["workfile"] + families = ["texture_batch_workfile"] optional = True # from presets diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index 1df7512588..36f0b0c954 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -50,12 +50,12 @@ class ExtractSequence(pyblish.api.Extractor): mark_in = instance.context.data["sceneMarkIn"] mark_out = instance.context.data["sceneMarkOut"] - # Scene start frame offsets the output files, so we need to offset the - # marks. + # Change scene Start Frame to 0 to prevent frame index issues + # - issue is that TVPaint versions deal with frame indexes in a + # different way when Start Frame is not `0` + # NOTE It will be set back after rendering scene_start_frame = instance.context.data["sceneStartFrame"] - difference = scene_start_frame - mark_in - mark_in += difference - mark_out += difference + lib.execute_george("tv_startframe 0") # Frame start/end may be stored as float frame_start = int(instance.data["frameStart"]) @@ -145,6 +145,9 @@ class ExtractSequence(pyblish.api.Extractor): filtered_layers ) + # Change scene frame Start back to previous value + lib.execute_george("tv_startframe {}".format(scene_start_frame)) + # Sequence of one frame if not output_filenames: self.log.warning("Extractor did not create any output.") diff --git a/openpype/lib/abstract_submit_deadline.py b/openpype/lib/abstract_submit_deadline.py index 4a052a4ee2..5b6e1743e0 100644 --- a/openpype/lib/abstract_submit_deadline.py +++ b/openpype/lib/abstract_submit_deadline.py @@ -415,13 +415,11 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin): """Plugin entry point.""" self._instance = instance context = instance.context - self._deadline_url = ( - context.data["system_settings"] - ["modules"] - ["deadline"] - ["DEADLINE_REST_URL"] - ) - assert self._deadline_url, "Requires DEADLINE_REST_URL" + self._deadline_url = context.data.get("defaultDeadline") + self._deadline_url = instance.data.get( + "deadlineUrl", self._deadline_url) + + assert self._deadline_url, "Requires Deadline Webservice URL" file_path = None if self.use_published: diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 42fc5cd848..dc14090114 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1140,7 +1140,8 @@ def prepare_host_environments(data, implementation_envs=True): # Merge dictionaries env_values = _merge_env(tool_env, env_values) - loaded_env = _merge_env(acre.compute(env_values), data["env"]) + merged_env = _merge_env(env_values, data["env"]) + loaded_env = acre.compute(merged_env, cleanup=False) final_env = None # Add host specific environments @@ -1191,7 +1192,10 @@ def apply_project_environments_value(project_name, env, project_settings=None): env_value = project_settings["global"]["project_environments"] if env_value: - env.update(_merge_env(acre.parse(env_value), env)) + env.update(acre.compute( + _merge_env(acre.parse(env_value), env), + cleanup=False + )) return env diff --git a/openpype/lib/log.py b/openpype/lib/log.py index 39b6c67080..85cbc733ba 100644 --- a/openpype/lib/log.py +++ b/openpype/lib/log.py @@ -72,6 +72,8 @@ class PypeStreamHandler(logging.StreamHandler): msg = self.format(record) msg = Terminal.log(msg) stream = self.stream + if stream is None: + return fs = "%s\n" # if no unicode support... if not USE_UNICODE: diff --git a/openpype/modules/__init__.py b/openpype/modules/__init__.py index d6fb9c0aef..068aeb98af 100644 --- a/openpype/modules/__init__.py +++ b/openpype/modules/__init__.py @@ -38,6 +38,7 @@ from .muster import MusterModule from .deadline import DeadlineModule from .project_manager_action import ProjectManagerAction from .standalonepublish_action import StandAlonePublishAction +from .python_console_interpreter import PythonInterpreterAction from .sync_server import SyncServerModule from .slack import SlackIntegrationModule @@ -77,6 +78,7 @@ __all__ = ( "DeadlineModule", "ProjectManagerAction", "StandAlonePublishAction", + "PythonInterpreterAction", "SyncServerModule", diff --git a/openpype/modules/deadline/deadline_module.py b/openpype/modules/deadline/deadline_module.py index 2a2fba41d6..a07cb1a660 100644 --- a/openpype/modules/deadline/deadline_module.py +++ b/openpype/modules/deadline/deadline_module.py @@ -6,17 +6,25 @@ from openpype.modules import ( class DeadlineModule(PypeModule, IPluginPaths): name = "deadline" + def __init__(self, manager, settings): + self.deadline_urls = {} + super(DeadlineModule, self).__init__(manager, settings) + def initialize(self, modules_settings): # This module is always enabled deadline_settings = modules_settings[self.name] self.enabled = deadline_settings["enabled"] - self.deadline_url = deadline_settings["DEADLINE_REST_URL"] + deadline_url = deadline_settings.get("DEADLINE_REST_URL") + if deadline_url: + self.deadline_urls = {"default": deadline_url} + else: + self.deadline_urls = deadline_settings.get("deadline_urls") # noqa: E501 - def get_global_environments(self): - """Deadline global environments for OpenPype implementation.""" - return { - "DEADLINE_REST_URL": self.deadline_url - } + if not self.deadline_urls: + self.enabled = False + self.log.warning(("default Deadline Webservice URL " + "not specified. Disabling module.")) + return def connect_with_modules(self, *_a, **_kw): return diff --git a/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py new file mode 100644 index 0000000000..784616615d --- /dev/null +++ b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +"""Collect Deadline servers from instance. + +This is resolving index of server lists stored in `deadlineServers` instance +attribute or using default server if that attribute doesn't exists. + +""" +import pyblish.api + + +class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): + """Collect Deadline Webservice URL from instance.""" + + order = pyblish.api.CollectorOrder + label = "Deadline Webservice from the Instance" + families = ["rendering"] + + def process(self, instance): + instance.data["deadlineUrl"] = self._collect_deadline_url(instance) + self.log.info( + "Using {} for submission.".format(instance.data["deadlineUrl"])) + + @staticmethod + def _collect_deadline_url(render_instance): + # type: (pyblish.api.Instance) -> str + """Get Deadline Webservice URL from render instance. + + This will get all configured Deadline Webservice URLs and create + subset of them based upon project configuration. It will then take + `deadlineServers` from render instance that is now basically `int` + index of that list. + + Args: + render_instance (pyblish.api.Instance): Render instance created + by Creator in Maya. + + Returns: + str: Selected Deadline Webservice URL. + + """ + + deadline_settings = ( + render_instance.context.data + ["system_settings"] + ["modules"] + ["deadline"] + ) + + try: + default_servers = deadline_settings["deadline_urls"] + project_servers = ( + render_instance.context.data + ["project_settings"] + ["deadline"] + ["deadline_servers"] + ) + deadline_servers = { + k: default_servers[k] + for k in project_servers + if k in default_servers + } + + except AttributeError: + # Handle situation were we had only one url for deadline. + return render_instance.context.data["defaultDeadline"] + + return deadline_servers[ + list(deadline_servers.keys())[ + int(render_instance.data.get("deadlineServers")) + ] + ] diff --git a/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py b/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py new file mode 100644 index 0000000000..53231bd7e4 --- /dev/null +++ b/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +"""Collect default Deadline server.""" +import pyblish.api + + +class CollectDefaultDeadlineServer(pyblish.api.ContextPlugin): + """Collect default Deadline Webservice URL.""" + + order = pyblish.api.CollectorOrder + 0.01 + label = "Default Deadline Webservice" + + def process(self, context): + try: + deadline_module = context.data.get("openPypeModules")["deadline"] + except AttributeError: + self.log.error("Cannot get OpenPype Deadline module.") + raise AssertionError("OpenPype Deadline module not found.") + + # get default deadline webservice url from deadline module + self.log.debug(deadline_module.deadline_urls) + context.data["defaultDeadline"] = deadline_module.deadline_urls["default"] # noqa: E501 diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index a652da7786..1ab3dc2554 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -199,7 +199,7 @@ def get_renderer_variables(renderlayer, root): if extension is None: extension = "png" - if extension == "exr (multichannel)" or extension == "exr (deep)": + if extension in ["exr (multichannel)", "exr (deep)"]: extension = "exr" prefix_attr = "vraySettings.fileNamePrefix" @@ -264,12 +264,13 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): self._instance = instance self.payload_skeleton = copy.deepcopy(payload_skeleton_template) - self._deadline_url = ( - context.data["system_settings"] - ["modules"] - ["deadline"] - ["DEADLINE_REST_URL"] - ) + + # get default deadline webservice url from deadline module + self.deadline_url = instance.context.data.get("defaultDeadline") + # if custom one is set in instance, use that + if instance.data.get("deadlineUrl"): + self.deadline_url = instance.data.get("deadlineUrl") + assert self.deadline_url, "Requires Deadline Webservice URL" self._job_info = ( context.data["project_settings"].get( @@ -287,65 +288,76 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): "pluginInfo", {}) ) - assert self._deadline_url, "Requires DEADLINE_REST_URL" - context = instance.context workspace = context.data["workspaceDir"] anatomy = context.data['anatomy'] instance.data["toBeRenderedOn"] = "deadline" filepath = None + patches = ( + context.data["project_settings"].get( + "deadline", {}).get( + "publish", {}).get( + "MayaSubmitDeadline", {}).get( + "scene_patches", {}) + ) # Handle render/export from published scene or not ------------------ if self.use_published: + patched_files = [] for i in context: - if "workfile" in i.data["families"]: - assert i.data["publish"] is True, ( - "Workfile (scene) must be published along") - template_data = i.data.get("anatomyData") - rep = i.data.get("representations")[0].get("name") - template_data["representation"] = rep - template_data["ext"] = rep - template_data["comment"] = None - anatomy_filled = anatomy.format(template_data) - template_filled = anatomy_filled["publish"]["path"] - filepath = os.path.normpath(template_filled) - self.log.info("Using published scene for render {}".format( - filepath)) + if "workfile" not in i.data["families"]: + continue + assert i.data["publish"] is True, ( + "Workfile (scene) must be published along") + template_data = i.data.get("anatomyData") + rep = i.data.get("representations")[0].get("name") + template_data["representation"] = rep + template_data["ext"] = rep + template_data["comment"] = None + anatomy_filled = anatomy.format(template_data) + template_filled = anatomy_filled["publish"]["path"] + filepath = os.path.normpath(template_filled) + self.log.info("Using published scene for render {}".format( + filepath)) - if not os.path.exists(filepath): - self.log.error("published scene does not exist!") - raise - # now we need to switch scene in expected files - # because token will now point to published - # scene file and that might differ from current one - new_scene = os.path.splitext( - os.path.basename(filepath))[0] - orig_scene = os.path.splitext( - os.path.basename(context.data["currentFile"]))[0] - exp = instance.data.get("expectedFiles") + if not os.path.exists(filepath): + self.log.error("published scene does not exist!") + raise + # now we need to switch scene in expected files + # because token will now point to published + # scene file and that might differ from current one + new_scene = os.path.splitext( + os.path.basename(filepath))[0] + orig_scene = os.path.splitext( + os.path.basename(context.data["currentFile"]))[0] + exp = instance.data.get("expectedFiles") - if isinstance(exp[0], dict): - # we have aovs and we need to iterate over them - new_exp = {} - for aov, files in exp[0].items(): - replaced_files = [] - for f in files: - replaced_files.append( - f.replace(orig_scene, new_scene) - ) - new_exp[aov] = replaced_files - instance.data["expectedFiles"] = [new_exp] - else: - new_exp = [] - for f in exp: - new_exp.append( + if isinstance(exp[0], dict): + # we have aovs and we need to iterate over them + new_exp = {} + for aov, files in exp[0].items(): + replaced_files = [] + for f in files: + replaced_files.append( f.replace(orig_scene, new_scene) ) - instance.data["expectedFiles"] = [new_exp] - self.log.info("Scene name was switched {} -> {}".format( - orig_scene, new_scene - )) + new_exp[aov] = replaced_files + instance.data["expectedFiles"] = [new_exp] + else: + new_exp = [] + for f in exp: + new_exp.append( + f.replace(orig_scene, new_scene) + ) + instance.data["expectedFiles"] = [new_exp] + self.log.info("Scene name was switched {} -> {}".format( + orig_scene, new_scene + )) + # patch workfile is needed + if filepath not in patched_files: + patched_file = self._patch_workfile(filepath, patches) + patched_files.append(patched_file) all_instances = [] for result in context.data["results"]: @@ -670,7 +682,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): self.log.info( "Submitting tile job(s) [{}] ...".format(len(frame_payloads))) - url = "{}/api/jobs".format(self._deadline_url) + url = "{}/api/jobs".format(self.deadline_url) tiles_count = instance.data.get("tilesX") * instance.data.get("tilesY") # noqa: E501 for tile_job in frame_payloads: @@ -754,7 +766,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # E.g. http://192.168.0.1:8082/api/jobs - url = "{}/api/jobs".format(self._deadline_url) + url = "{}/api/jobs".format(self.deadline_url) response = self._requests_post(url, json=payload) if not response.ok: raise Exception(response.text) @@ -868,10 +880,11 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): payload["JobInfo"].update(job_info_ext) payload["PluginInfo"].update(plugin_info_ext) - envs = [] - for k, v in payload["JobInfo"].items(): - if k.startswith("EnvironmentKeyValue"): - envs.append(v) + envs = [ + v + for k, v in payload["JobInfo"].items() + if k.startswith("EnvironmentKeyValue") + ] # add app name to environment envs.append( @@ -892,11 +905,8 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): envs.append( "OPENPYPE_ASS_EXPORT_STEP={}".format(1)) - i = 0 - for e in envs: + for i, e in enumerate(envs): payload["JobInfo"]["EnvironmentKeyValue{}".format(i)] = e - i += 1 - return payload def _get_vray_render_payload(self, data): @@ -964,7 +974,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): payload = self._get_arnold_export_payload(data) self.log.info("Submitting ass export job.") - url = "{}/api/jobs".format(self._deadline_url) + url = "{}/api/jobs".format(self.deadline_url) response = self._requests_post(url, json=payload) if not response.ok: self.log.error("Submition failed!") @@ -1003,7 +1013,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): """ if 'verify' not in kwargs: - kwargs['verify'] = False if os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) else True # noqa + kwargs['verify'] = not os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) # add 10sec timeout before bailing out kwargs['timeout'] = 10 return requests.post(*args, **kwargs) @@ -1022,7 +1032,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): """ if 'verify' not in kwargs: - kwargs['verify'] = False if os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) else True # noqa + kwargs['verify'] = not os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) # add 10sec timeout before bailing out kwargs['timeout'] = 10 return requests.get(*args, **kwargs) @@ -1069,3 +1079,43 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): result = filename_zero.replace("\\", "/") return result + + def _patch_workfile(self, file, patches): + # type: (str, dict) -> [str, None] + """Patch Maya scene. + + This will take list of patches (lines to add) and apply them to + *published* Maya scene file (that is used later for rendering). + + Patches are dict with following structure:: + { + "name": "Name of patch", + "regex": "regex of line before patch", + "line": "line to insert" + } + + Args: + file (str): File to patch. + patches (dict): Dictionary defining patches. + + Returns: + str: Patched file path or None + + """ + if os.path.splitext(file)[1].lower() != ".ma" or not patches: + return None + + compiled_regex = [re.compile(p["regex"]) for p in patches] + with open(file, "r+") as pf: + scene_data = pf.readlines() + for ln, line in enumerate(scene_data): + for i, r in enumerate(compiled_regex): + if re.match(r, line): + scene_data.insert(ln + 1, patches[i]["line"]) + pf.seek(0) + pf.writelines(scene_data) + pf.truncate() + self.log.info( + "Applied {} patch to scene.".format( + patches[i]["name"])) + return file diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index fed98d8a08..4cba35963c 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -42,13 +42,12 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin): node = instance[0] context = instance.context - deadline_url = ( - context.data["system_settings"] - ["modules"] - ["deadline"] - ["DEADLINE_REST_URL"] - ) - assert deadline_url, "Requires DEADLINE_REST_URL" + # get default deadline webservice url from deadline module + deadline_url = instance.context.data["defaultDeadline"] + # if custom one is set in instance, use that + if instance.data.get("deadlineUrl"): + deadline_url = instance.data.get("deadlineUrl") + assert deadline_url, "Requires Deadline Webservice URL" self.deadline_url = "{}/api/jobs".format(deadline_url) self._comment = context.data.get("comment", "") @@ -252,39 +251,11 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin): environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **api.Session) - # self.log.debug("enviro: {}".format(pprint(environment))) for _path in os.environ: if _path.lower().startswith('openpype_'): environment[_path] = os.environ[_path] - clean_environment = {} - for key, value in environment.items(): - clean_path = "" - self.log.debug("key: {}".format(key)) - if "://" in value: - clean_path = value - else: - valid_paths = [] - for path in value.split(os.pathsep): - if not path: - continue - try: - path.decode('UTF-8', 'strict') - valid_paths.append(os.path.normpath(path)) - except UnicodeDecodeError: - print('path contains non UTF characters') - - if valid_paths: - clean_path = os.pathsep.join(valid_paths) - - if key == "PYTHONPATH": - clean_path = clean_path.replace('python2', 'python3') - - self.log.debug("clean path: {}".format(clean_path)) - clean_environment[key] = clean_path - - environment = clean_environment # to recognize job from PYPE for turning Event On/Off environment["OPENPYPE_RENDER_JOB"] = "1" diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 41f8337fd8..19e3174384 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -5,7 +5,6 @@ import os import json import re from copy import copy, deepcopy -import sys import openpype.api from avalon import api, io @@ -615,14 +614,16 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): instance["families"] = families def process(self, instance): + # type: (pyblish.api.Instance) -> None """Process plugin. Detect type of renderfarm submission and create and post dependend job in case of Deadline. It creates json file with metadata needed for publishing in directory of render. - :param instance: Instance data - :type instance: dict + Args: + instance (pyblish.api.Instance): Instance data. + """ data = instance.data.copy() context = instance.context @@ -908,13 +909,12 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): } if submission_type == "deadline": - self.deadline_url = ( - context.data["system_settings"] - ["modules"] - ["deadline"] - ["DEADLINE_REST_URL"] - ) - assert self.deadline_url, "Requires DEADLINE_REST_URL" + # get default deadline webservice url from deadline module + self.deadline_url = instance.context.data["defaultDeadline"] + # if custom one is set in instance, use that + if instance.data.get("deadlineUrl"): + self.deadline_url = instance.data.get("deadlineUrl") + assert self.deadline_url, "Requires Deadline Webservice URL" self._submit_deadline_post_job(instance, render_job, instances) diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py index 9b10619c0b..ff664d9f83 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py @@ -1,11 +1,10 @@ import pyblish.api from avalon.vendor import requests -from openpype.plugin import contextplugin_should_run import os -class ValidateDeadlineConnection(pyblish.api.ContextPlugin): +class ValidateDeadlineConnection(pyblish.api.InstancePlugin): """Validate Deadline Web Service is running""" label = "Validate Deadline Web Service" @@ -13,18 +12,16 @@ class ValidateDeadlineConnection(pyblish.api.ContextPlugin): hosts = ["maya", "nuke"] families = ["renderlayer"] - def process(self, context): - - # Workaround bug pyblish-base#250 - if not contextplugin_should_run(self, context): - return - - deadline_url = ( - context.data["system_settings"] - ["modules"] - ["deadline"] - ["DEADLINE_REST_URL"] - ) + def process(self, instance): + # get default deadline webservice url from deadline module + deadline_url = instance.context.data["defaultDeadline"] + # if custom one is set in instance, use that + if instance.data.get("deadlineUrl"): + deadline_url = instance.data.get("deadlineUrl") + self.log.info( + "We have deadline URL on instance {}".format( + deadline_url)) + assert deadline_url, "Requires Deadline Webservice URL" # Check response response = self._requests_get(deadline_url) diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index c71b5106ec..addd4a2e80 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -4,7 +4,6 @@ import pyblish.api from avalon.vendor import requests -from openpype.api import get_system_settings from openpype.lib.abstract_submit_deadline import requests_get from openpype.lib.delivery import collect_frames @@ -22,6 +21,7 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): allow_user_override = True def process(self, instance): + self.instance = instance frame_list = self._get_frame_list(instance.data["render_job_id"]) for repre in instance.data["representations"]: @@ -129,13 +129,12 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): Might be different than job info saved in metadata.json if user manually changes job pre/during rendering. """ - deadline_url = ( - get_system_settings() - ["modules"] - ["deadline"] - ["DEADLINE_REST_URL"] - ) - assert deadline_url, "Requires DEADLINE_REST_URL" + # get default deadline webservice url from deadline module + deadline_url = self.instance.context.data["defaultDeadline"] + # if custom one is set in instance, use that + if self.instance.data.get("deadlineUrl"): + deadline_url = self.instance.data.get("deadlineUrl") + assert deadline_url, "Requires Deadline Webservice URL" url = "{}/api/jobs?JobID={}".format(deadline_url, job_id) try: @@ -181,6 +180,10 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): """Returns set of file names from metadata.json""" expected_files = set() - for file_name in repre["files"]: + files = repre["files"] + if not isinstance(files, list): + files = [files] + + for file_name in files: expected_files.add(file_name) return expected_files diff --git a/openpype/modules/ftrack/event_handlers_server/action_private_project_detection.py b/openpype/modules/ftrack/event_handlers_server/action_private_project_detection.py new file mode 100644 index 0000000000..5213e10ba3 --- /dev/null +++ b/openpype/modules/ftrack/event_handlers_server/action_private_project_detection.py @@ -0,0 +1,61 @@ +from openpype.modules.ftrack.lib import ServerAction + + +class PrivateProjectDetectionAction(ServerAction): + """Action helps to identify if does not have access to project.""" + + identifier = "server.missing.perm.private.project" + label = "Missing permissions" + description = ( + "Main ftrack event server does not have access to this project." + ) + + def _discover(self, event): + """Show action only if there is a selection in event data.""" + entities = self._translate_event(event) + if entities: + return None + + selection = event["data"].get("selection") + if not selection: + return None + + return { + "items": [{ + "label": self.label, + "variant": self.variant, + "description": self.description, + "actionIdentifier": self.discover_identifier, + "icon": self.icon, + }] + } + + def _launch(self, event): + # Ignore if there are values in event data + # - somebody clicked on submit button + values = event["data"].get("values") + if values: + return None + + title = "# Private project (missing permissions) #" + msg = ( + "User ({}) or API Key used on Ftrack event server" + " does not have permissions to access this private project." + ).format(self.session.api_user) + return { + "type": "form", + "title": "Missing permissions", + "items": [ + {"type": "label", "value": title}, + {"type": "label", "value": msg}, + # Add hidden to be able detect if was clicked on submit + {"type": "hidden", "value": "1", "name": "hidden"} + ], + "submit_button_label": "Got it" + } + + +def register(session): + '''Register plugin. Called when used as an plugin.''' + + PrivateProjectDetectionAction(session).register() diff --git a/openpype/modules/ftrack/event_handlers_user/action_where_run_ask.py b/openpype/modules/ftrack/event_handlers_user/action_where_run_ask.py index 6950d45ecd..2c427cfff7 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_where_run_ask.py +++ b/openpype/modules/ftrack/event_handlers_user/action_where_run_ask.py @@ -1,33 +1,98 @@ +import platform +import socket +import getpass + from openpype.modules.ftrack.lib import BaseAction, statics_icon -class ActionAskWhereIRun(BaseAction): - """ Sometimes user forget where pipeline with his credentials is running. - - this action triggers `ActionShowWhereIRun` - """ - ignore_me = True - identifier = 'ask.where.i.run' - label = 'Ask where I run' - description = 'Triggers PC info where user have running OpenPype' - icon = statics_icon("ftrack", "action_icons", "ActionAskWhereIRun.svg") +class ActionWhereIRun(BaseAction): + """Show where same user has running OpenPype instances.""" - def discover(self, session, entities, event): - """ Hide by default - Should be enabled only if you want to run. - - best practise is to create another action that triggers this one - """ + identifier = "ask.where.i.run" + show_identifier = "show.where.i.run" + label = "OpenPype Admin" + variant = "- Where I run" + description = "Show PC info where user have running OpenPype" - return True + def _discover(self, _event): + return { + "items": [{ + "label": self.label, + "variant": self.variant, + "description": self.description, + "actionIdentifier": self.discover_identifier, + "icon": self.icon, + }] + } - def launch(self, session, entities, event): - more_data = {"event_hub_id": session.event_hub.id} - self.trigger_action( - "show.where.i.run", event, additional_event_data=more_data + def _launch(self, event): + self.trigger_action(self.show_identifier, event) + + def register(self): + # Register default action callbacks + super(ActionWhereIRun, self).register() + + # Add show identifier + show_subscription = ( + "topic=ftrack.action.launch" + " and data.actionIdentifier={}" + " and source.user.username={}" + ).format( + self.show_identifier, + self.session.api_user + ) + self.session.event_hub.subscribe( + show_subscription, + self._show_info ) - return True + def _show_info(self, event): + title = "Where Do I Run?" + msgs = {} + all_keys = ["Hostname", "IP", "Username", "System name", "PC name"] + try: + host_name = socket.gethostname() + msgs["Hostname"] = host_name + host_ip = socket.gethostbyname(host_name) + msgs["IP"] = host_ip + except Exception: + pass + + try: + system_name, pc_name, *_ = platform.uname() + msgs["System name"] = system_name + msgs["PC name"] = pc_name + except Exception: + pass + + try: + msgs["Username"] = getpass.getuser() + except Exception: + pass + + for key in all_keys: + if not msgs.get(key): + msgs[key] = "-Undefined-" + + items = [] + first = True + separator = {"type": "label", "value": "---"} + for key, value in msgs.items(): + if first: + first = False + else: + items.append(separator) + self.log.debug("{}: {}".format(key, value)) + + subtitle = {"type": "label", "value": "

{}

".format(key)} + items.append(subtitle) + message = {"type": "label", "value": "

{}

".format(value)} + items.append(message) + + self.show_interface(items, title, event=event) def register(session): '''Register plugin. Called when used as an plugin.''' - ActionAskWhereIRun(session).register() + ActionWhereIRun(session).register() diff --git a/openpype/modules/ftrack/event_handlers_user/action_where_run_show.py b/openpype/modules/ftrack/event_handlers_user/action_where_run_show.py deleted file mode 100644 index b8b49e86cb..0000000000 --- a/openpype/modules/ftrack/event_handlers_user/action_where_run_show.py +++ /dev/null @@ -1,86 +0,0 @@ -import platform -import socket -import getpass -from openpype.modules.ftrack.lib import BaseAction - - -class ActionShowWhereIRun(BaseAction): - """ Sometimes user forget where pipeline with his credentials is running. - - this action shows on which PC, Username and IP is running - - requirement action MUST be registered where we want to locate the PC: - - - can't be used retrospectively... - """ - #: Action identifier. - identifier = 'show.where.i.run' - #: Action label. - label = 'Show where I run' - #: Action description. - description = 'Shows PC info where user have running OpenPype' - - def discover(self, session, entities, event): - """ Hide by default - Should be enabled only if you want to run. - - best practise is to create another action that triggers this one - """ - - return False - - @property - def launch_identifier(self): - return self.identifier - - def launch(self, session, entities, event): - # Don't show info when was launch from this session - if session.event_hub.id == event.get("data", {}).get("event_hub_id"): - return True - - title = "Where Do I Run?" - msgs = {} - all_keys = ["Hostname", "IP", "Username", "System name", "PC name"] - try: - host_name = socket.gethostname() - msgs["Hostname"] = host_name - host_ip = socket.gethostbyname(host_name) - msgs["IP"] = host_ip - except Exception: - pass - - try: - system_name, pc_name, *_ = platform.uname() - msgs["System name"] = system_name - msgs["PC name"] = pc_name - except Exception: - pass - - try: - msgs["Username"] = getpass.getuser() - except Exception: - pass - - for key in all_keys: - if not msgs.get(key): - msgs[key] = "-Undefined-" - - items = [] - first = True - splitter = {'type': 'label', 'value': '---'} - for key, value in msgs.items(): - if first: - first = False - else: - items.append(splitter) - self.log.debug("{}: {}".format(key, value)) - - subtitle = {'type': 'label', 'value': '

{}

'.format(key)} - items.append(subtitle) - message = {'type': 'label', 'value': '

{}

'.format(value)} - items.append(message) - - self.show_interface(items, title, event=event) - - return True - - -def register(session): - '''Register plugin. Called when used as an plugin.''' - - ActionShowWhereIRun(session).register() diff --git a/openpype/modules/ftrack/lib/ftrack_base_handler.py b/openpype/modules/ftrack/lib/ftrack_base_handler.py index b8be287a03..7b7ebfb099 100644 --- a/openpype/modules/ftrack/lib/ftrack_base_handler.py +++ b/openpype/modules/ftrack/lib/ftrack_base_handler.py @@ -191,15 +191,22 @@ class BaseHandler(object): if session is None: session = self.session - _entities = event['data'].get('entities_object', None) + _entities = event["data"].get("entities_object", None) + if _entities is not None and not _entities: + return _entities + if ( - _entities is None or - _entities[0].get( - 'link', None + _entities is None + or _entities[0].get( + "link", None ) == ftrack_api.symbol.NOT_SET ): - _entities = self._get_entities(event) - event['data']['entities_object'] = _entities + _entities = [ + item + for item in self._get_entities(event) + if item is not None + ] + event["data"]["entities_object"] = _entities return _entities diff --git a/openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py b/openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py index 8464a43ef7..cc2a5b7d37 100644 --- a/openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py +++ b/openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py @@ -63,8 +63,9 @@ class CollectFtrackFamily(pyblish.api.InstancePlugin): self.log.debug("Adding ftrack family for '{}'". format(instance.data.get("family"))) - if families and "ftrack" not in families: - instance.data["families"].append("ftrack") + if families: + if "ftrack" not in families: + instance.data["families"].append("ftrack") else: instance.data["families"] = ["ftrack"] else: diff --git a/openpype/modules/python_console_interpreter/__init__.py b/openpype/modules/python_console_interpreter/__init__.py new file mode 100644 index 0000000000..5f54ac497b --- /dev/null +++ b/openpype/modules/python_console_interpreter/__init__.py @@ -0,0 +1,8 @@ +from .module import ( + PythonInterpreterAction +) + + +__all__ = ( + "PythonInterpreterAction", +) diff --git a/openpype/modules/python_console_interpreter/module.py b/openpype/modules/python_console_interpreter/module.py new file mode 100644 index 0000000000..b37f35dfe0 --- /dev/null +++ b/openpype/modules/python_console_interpreter/module.py @@ -0,0 +1,45 @@ +from .. import PypeModule, ITrayAction + + +class PythonInterpreterAction(PypeModule, ITrayAction): + label = "Console" + name = "python_interpreter" + admin_action = True + + def initialize(self, modules_settings): + self.enabled = True + self._interpreter_window = None + + def tray_init(self): + self.create_interpreter_window() + + def tray_exit(self): + 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: + return + + from openpype.modules.python_console_interpreter.window import ( + PythonInterpreterWidget + ) + + self._interpreter_window = PythonInterpreterWidget() + + def on_action_trigger(self): + self.show_interpreter_window() + + def show_interpreter_window(self): + self.create_interpreter_window() + + if self._interpreter_window.isVisible(): + self._interpreter_window.activateWindow() + self._interpreter_window.raise_() + return + + self._interpreter_window.show() diff --git a/openpype/modules/python_console_interpreter/window/__init__.py b/openpype/modules/python_console_interpreter/window/__init__.py new file mode 100644 index 0000000000..92fd6f1df2 --- /dev/null +++ b/openpype/modules/python_console_interpreter/window/__init__.py @@ -0,0 +1,8 @@ +from .widgets import ( + PythonInterpreterWidget +) + + +__all__ = ( + "PythonInterpreterWidget", +) diff --git a/openpype/modules/python_console_interpreter/window/widgets.py b/openpype/modules/python_console_interpreter/window/widgets.py new file mode 100644 index 0000000000..975decf4f4 --- /dev/null +++ b/openpype/modules/python_console_interpreter/window/widgets.py @@ -0,0 +1,583 @@ +import os +import re +import sys +import collections +from code import InteractiveInterpreter + +import appdirs +from Qt import QtCore, QtWidgets, QtGui + +from openpype import resources +from openpype.style import load_stylesheet +from openpype.lib import JSONSettingRegistry + + +openpype_art = """ + . . .. . .. + _oOOP3OPP3Op_. . + .PPpo~. .. ~2p. .. .... . . + .Ppo . .pPO3Op.. . O:. . . . + .3Pp . oP3'. 'P33. . 4 .. . . . .. . . . + .~OP 3PO. .Op3 : . .. _____ _____ _____ + .P3O . oP3oP3O3P' . . . . / /./ /./ / + O3:. O3p~ . .:. . ./____/./____/ /____/ + 'P . 3p3. oP3~. ..P:. . . .. . . .. . . . + . ': . Po' .Opo'. .3O. . o[ by Pype Club ]]]==- - - . . + . '_ .. . . _OP3.. . .https://openpype.io.. . + ~P3.OPPPO3OP~ . .. . + . ' '. . .. . . . .. . + + +""" + + +class PythonInterpreterRegistry(JSONSettingRegistry): + """Class handling OpenPype general settings registry. + + Attributes: + vendor (str): Name used for path construction. + product (str): Additional name used for path construction. + + """ + + def __init__(self): + self.vendor = "pypeclub" + self.product = "openpype" + name = "python_interpreter_tool" + path = appdirs.user_data_dir(self.product, self.vendor) + super(PythonInterpreterRegistry, self).__init__(name, path) + + +class StdOEWrap: + def __init__(self): + self._origin_stdout_write = None + self._origin_stderr_write = None + self._listening = False + self.lines = collections.deque() + + if not sys.stdout: + sys.stdout = open(os.devnull, "w") + + if not sys.stderr: + sys.stderr = open(os.devnull, "w") + + if self._origin_stdout_write is None: + self._origin_stdout_write = sys.stdout.write + + if self._origin_stderr_write is None: + self._origin_stderr_write = sys.stderr.write + + self._listening = True + sys.stdout.write = self._stdout_listener + sys.stderr.write = self._stderr_listener + + def stop_listen(self): + self._listening = False + + def _stdout_listener(self, text): + if self._listening: + self.lines.append(text) + if self._origin_stdout_write is not None: + self._origin_stdout_write(text) + + def _stderr_listener(self, text): + if self._listening: + self.lines.append(text) + if self._origin_stderr_write is not None: + self._origin_stderr_write(text) + + +class PythonCodeEditor(QtWidgets.QPlainTextEdit): + execute_requested = QtCore.Signal() + + def __init__(self, parent): + super(PythonCodeEditor, self).__init__(parent) + + self.setObjectName("PythonCodeEditor") + + self._indent = 4 + + def _tab_shift_right(self): + cursor = self.textCursor() + selected_text = cursor.selectedText() + if not selected_text: + cursor.insertText(" " * self._indent) + return + + sel_start = cursor.selectionStart() + sel_end = cursor.selectionEnd() + cursor.setPosition(sel_end) + end_line = cursor.blockNumber() + cursor.setPosition(sel_start) + while True: + cursor.movePosition(QtGui.QTextCursor.StartOfLine) + text = cursor.block().text() + spaces = len(text) - len(text.lstrip(" ")) + new_spaces = spaces % self._indent + if not new_spaces: + new_spaces = self._indent + + cursor.insertText(" " * new_spaces) + if cursor.blockNumber() == end_line: + break + + cursor.movePosition(QtGui.QTextCursor.NextBlock) + + def _tab_shift_left(self): + tmp_cursor = self.textCursor() + sel_start = tmp_cursor.selectionStart() + sel_end = tmp_cursor.selectionEnd() + + cursor = QtGui.QTextCursor(self.document()) + cursor.setPosition(sel_end) + end_line = cursor.blockNumber() + cursor.setPosition(sel_start) + while True: + cursor.movePosition(QtGui.QTextCursor.StartOfLine) + text = cursor.block().text() + spaces = len(text) - len(text.lstrip(" ")) + if spaces: + spaces_to_remove = (spaces % self._indent) or self._indent + if spaces_to_remove > spaces: + spaces_to_remove = spaces + + cursor.setPosition( + cursor.position() + spaces_to_remove, + QtGui.QTextCursor.KeepAnchor + ) + cursor.removeSelectedText() + + if cursor.blockNumber() == end_line: + break + + cursor.movePosition(QtGui.QTextCursor.NextBlock) + + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key_Backtab: + self._tab_shift_left() + event.accept() + return + + if event.key() == QtCore.Qt.Key_Tab: + if event.modifiers() == QtCore.Qt.NoModifier: + self._tab_shift_right() + event.accept() + return + + if ( + event.key() == QtCore.Qt.Key_Return + and event.modifiers() == QtCore.Qt.ControlModifier + ): + self.execute_requested.emit() + event.accept() + return + + super(PythonCodeEditor, self).keyPressEvent(event) + + +class PythonTabWidget(QtWidgets.QWidget): + before_execute = QtCore.Signal(str) + + def __init__(self, parent): + super(PythonTabWidget, self).__init__(parent) + + code_input = PythonCodeEditor(self) + + self.setFocusProxy(code_input) + + execute_btn = QtWidgets.QPushButton("Execute", self) + execute_btn.setToolTip("Execute command (Ctrl + Enter)") + + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(execute_btn) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(code_input, 1) + layout.addLayout(btns_layout, 0) + + execute_btn.clicked.connect(self._on_execute_clicked) + code_input.execute_requested.connect(self.execute) + + self._code_input = code_input + self._interpreter = InteractiveInterpreter() + + def _on_execute_clicked(self): + self.execute() + + def get_code(self): + return self._code_input.toPlainText() + + def set_code(self, code_text): + self._code_input.setPlainText(code_text) + + def execute(self): + code_text = self._code_input.toPlainText() + self.before_execute.emit(code_text) + self._interpreter.runcode(code_text) + + +class TabNameDialog(QtWidgets.QDialog): + default_width = 330 + default_height = 85 + + def __init__(self, parent): + super(TabNameDialog, self).__init__(parent) + + self.setWindowTitle("Enter tab name") + + name_label = QtWidgets.QLabel("Tab name:", self) + name_input = QtWidgets.QLineEdit(self) + + inputs_layout = QtWidgets.QHBoxLayout() + inputs_layout.addWidget(name_label) + inputs_layout.addWidget(name_input) + + ok_btn = QtWidgets.QPushButton("Ok", self) + cancel_btn = QtWidgets.QPushButton("Cancel", self) + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn) + btns_layout.addWidget(cancel_btn) + + layout = QtWidgets.QVBoxLayout(self) + layout.addLayout(inputs_layout) + layout.addStretch(1) + layout.addLayout(btns_layout) + + ok_btn.clicked.connect(self._on_ok_clicked) + cancel_btn.clicked.connect(self._on_cancel_clicked) + + self._name_input = name_input + self._ok_btn = ok_btn + self._cancel_btn = cancel_btn + + self._result = None + + self.resize(self.default_width, self.default_height) + + def set_tab_name(self, name): + self._name_input.setText(name) + + def result(self): + return self._result + + def showEvent(self, event): + super(TabNameDialog, self).showEvent(event) + btns_width = max( + self._ok_btn.width(), + self._cancel_btn.width() + ) + + self._ok_btn.setMinimumWidth(btns_width) + self._cancel_btn.setMinimumWidth(btns_width) + + def _on_ok_clicked(self): + self._result = self._name_input.text() + self.accept() + + def _on_cancel_clicked(self): + self._result = None + self.reject() + + +class OutputTextWidget(QtWidgets.QTextEdit): + v_max_offset = 4 + + def vertical_scroll_at_max(self): + v_scroll = self.verticalScrollBar() + return v_scroll.value() > v_scroll.maximum() - self.v_max_offset + + def scroll_to_bottom(self): + v_scroll = self.verticalScrollBar() + return v_scroll.setValue(v_scroll.maximum()) + + +class EnhancedTabBar(QtWidgets.QTabBar): + double_clicked = QtCore.Signal(QtCore.QPoint) + right_clicked = QtCore.Signal(QtCore.QPoint) + mid_clicked = QtCore.Signal(QtCore.QPoint) + + def __init__(self, parent): + super(EnhancedTabBar, self).__init__(parent) + + self.setDrawBase(False) + + def mouseDoubleClickEvent(self, event): + self.double_clicked.emit(event.globalPos()) + event.accept() + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.RightButton: + self.right_clicked.emit(event.globalPos()) + event.accept() + return + + elif event.button() == QtCore.Qt.MidButton: + self.mid_clicked.emit(event.globalPos()) + event.accept() + + else: + super(EnhancedTabBar, self).mouseReleaseEvent(event) + + +class PythonInterpreterWidget(QtWidgets.QWidget): + default_width = 1000 + default_height = 600 + + def __init__(self, parent=None): + super(PythonInterpreterWidget, self).__init__(parent) + + self.setWindowTitle("OpenPype Console") + self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) + + self.ansi_escape = re.compile( + r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]" + ) + + self._tabs = [] + + self._stdout_err_wrapper = StdOEWrap() + + output_widget = OutputTextWidget(self) + output_widget.setObjectName("PythonInterpreterOutput") + output_widget.setLineWrapMode(QtWidgets.QTextEdit.NoWrap) + output_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + + tab_widget = QtWidgets.QTabWidget(self) + tab_bar = EnhancedTabBar(tab_widget) + tab_widget.setTabBar(tab_bar) + tab_widget.setTabsClosable(False) + tab_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + add_tab_btn = QtWidgets.QPushButton("+", tab_widget) + tab_widget.setCornerWidget(add_tab_btn, QtCore.Qt.TopLeftCorner) + + widgets_splitter = QtWidgets.QSplitter(self) + widgets_splitter.setOrientation(QtCore.Qt.Vertical) + widgets_splitter.addWidget(output_widget) + widgets_splitter.addWidget(tab_widget) + widgets_splitter.setStretchFactor(0, 1) + widgets_splitter.setStretchFactor(1, 1) + height = int(self.default_height / 2) + widgets_splitter.setSizes([height, self.default_height - height]) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(widgets_splitter) + + line_check_timer = QtCore.QTimer() + line_check_timer.setInterval(200) + + line_check_timer.timeout.connect(self._on_timer_timeout) + add_tab_btn.clicked.connect(self._on_add_clicked) + tab_bar.right_clicked.connect(self._on_tab_right_click) + tab_bar.double_clicked.connect(self._on_tab_double_click) + tab_bar.mid_clicked.connect(self._on_tab_mid_click) + tab_widget.tabCloseRequested.connect(self._on_tab_close_req) + + self._widgets_splitter = widgets_splitter + self._add_tab_btn = add_tab_btn + self._output_widget = output_widget + self._tab_widget = tab_widget + self._line_check_timer = line_check_timer + + self._append_lines([openpype_art]) + + self.setStyleSheet(load_stylesheet()) + + self.resize(self.default_width, self.default_height) + + self._init_from_registry() + + if self._tab_widget.count() < 1: + self.add_tab("Python") + + def _init_from_registry(self): + setting_registry = PythonInterpreterRegistry() + + 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 + + try: + sizes = setting_registry.get_item("splitter_sizes") + if len(sizes) == len(self._widgets_splitter.sizes()): + self._widgets_splitter.setSizes(sizes) + + except ValueError: + pass + + try: + tab_defs = setting_registry.get_item("tabs") or [] + for tab_def in tab_defs: + widget = self.add_tab(tab_def["name"]) + widget.set_code(tab_def["code"]) + + except ValueError: + pass + + def save_registry(self): + setting_registry = PythonInterpreterRegistry() + + setting_registry.set_item("width", self.width()) + setting_registry.set_item("height", self.height()) + + setting_registry.set_item( + "splitter_sizes", self._widgets_splitter.sizes() + ) + + tabs = [] + for tab_idx in range(self._tab_widget.count()): + widget = self._tab_widget.widget(tab_idx) + tab_code = widget.get_code() + tab_name = self._tab_widget.tabText(tab_idx) + tabs.append({ + "name": tab_name, + "code": tab_code + }) + + setting_registry.set_item("tabs", tabs) + + def _on_tab_right_click(self, global_point): + point = self._tab_widget.mapFromGlobal(global_point) + tab_bar = self._tab_widget.tabBar() + tab_idx = tab_bar.tabAt(point) + last_index = tab_bar.count() - 1 + if tab_idx < 0 or tab_idx > last_index: + return + + menu = QtWidgets.QMenu(self._tab_widget) + menu.addAction("Rename") + result = menu.exec_(global_point) + if result is None: + return + + if result.text() == "Rename": + self._rename_tab_req(tab_idx) + + def _rename_tab_req(self, tab_idx): + dialog = TabNameDialog(self) + dialog.set_tab_name(self._tab_widget.tabText(tab_idx)) + dialog.exec_() + tab_name = dialog.result() + if tab_name: + self._tab_widget.setTabText(tab_idx, tab_name) + + def _on_tab_mid_click(self, global_point): + point = self._tab_widget.mapFromGlobal(global_point) + tab_bar = self._tab_widget.tabBar() + tab_idx = tab_bar.tabAt(point) + last_index = tab_bar.count() - 1 + if tab_idx < 0 or tab_idx > last_index: + return + + self._on_tab_close_req(tab_idx) + + def _on_tab_double_click(self, global_point): + point = self._tab_widget.mapFromGlobal(global_point) + tab_bar = self._tab_widget.tabBar() + tab_idx = tab_bar.tabAt(point) + last_index = tab_bar.count() - 1 + if tab_idx < 0 or tab_idx > last_index: + return + + self._rename_tab_req(tab_idx) + + def _on_tab_close_req(self, tab_index): + if self._tab_widget.count() == 1: + return + + widget = self._tab_widget.widget(tab_index) + if widget in self._tabs: + self._tabs.remove(widget) + self._tab_widget.removeTab(tab_index) + + if self._tab_widget.count() == 1: + self._tab_widget.setTabsClosable(False) + + def _append_lines(self, lines): + at_max = self._output_widget.vertical_scroll_at_max() + tmp_cursor = QtGui.QTextCursor(self._output_widget.document()) + tmp_cursor.movePosition(QtGui.QTextCursor.End) + for line in lines: + tmp_cursor.insertText(line) + + if at_max: + self._output_widget.scroll_to_bottom() + + def _on_timer_timeout(self): + if self._stdout_err_wrapper.lines: + lines = [] + while self._stdout_err_wrapper.lines: + line = self._stdout_err_wrapper.lines.popleft() + lines.append(self.ansi_escape.sub("", line)) + self._append_lines(lines) + + def _on_add_clicked(self): + dialog = TabNameDialog(self) + dialog.exec_() + tab_name = dialog.result() + if tab_name: + self.add_tab(tab_name) + + def _on_before_execute(self, code_text): + at_max = self._output_widget.vertical_scroll_at_max() + document = self._output_widget.document() + tmp_cursor = QtGui.QTextCursor(document) + tmp_cursor.movePosition(QtGui.QTextCursor.End) + tmp_cursor.insertText("{}\nExecuting command:\n".format(20 * "-")) + + code_block_format = QtGui.QTextFrameFormat() + code_block_format.setBackground(QtGui.QColor(27, 27, 27)) + code_block_format.setPadding(4) + + tmp_cursor.insertFrame(code_block_format) + char_format = tmp_cursor.charFormat() + char_format.setForeground( + QtGui.QBrush(QtGui.QColor(114, 224, 198)) + ) + tmp_cursor.setCharFormat(char_format) + tmp_cursor.insertText(code_text) + + # Create new cursor + tmp_cursor = QtGui.QTextCursor(document) + tmp_cursor.movePosition(QtGui.QTextCursor.End) + tmp_cursor.insertText("{}\n".format(20 * "-")) + + if at_max: + self._output_widget.scroll_to_bottom() + + def add_tab(self, tab_name, index=None): + widget = PythonTabWidget(self) + widget.before_execute.connect(self._on_before_execute) + if index is None: + if self._tab_widget.count() > 0: + index = self._tab_widget.currentIndex() + 1 + else: + index = 0 + + self._tabs.append(widget) + self._tab_widget.insertTab(index, widget, tab_name) + self._tab_widget.setCurrentIndex(index) + + if self._tab_widget.count() > 1: + self._tab_widget.setTabsClosable(True) + widget.setFocus() + return widget + + def showEvent(self, event): + self._line_check_timer.start() + super(PythonInterpreterWidget, self).showEvent(event) + self._output_widget.scroll_to_bottom() + + def closeEvent(self, event): + self.save_registry() + super(PythonInterpreterWidget, self).closeEvent(event) + self._line_check_timer.stop() diff --git a/openpype/plugins/publish/collect_modules.py b/openpype/plugins/publish/collect_modules.py new file mode 100644 index 0000000000..bec0c2b436 --- /dev/null +++ b/openpype/plugins/publish/collect_modules.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +"""Collect OpenPype modules.""" +from openpype.modules import ModulesManager +import pyblish.api + + +class CollectModules(pyblish.api.ContextPlugin): + """Collect OpenPype modules.""" + + order = pyblish.api.CollectorOrder + label = "OpenPype Modules" + + def process(self, context): + manager = ModulesManager() + context.data["openPypeModules"] = manager.modules_by_name diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 809cf438c8..02c574725d 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -45,7 +45,8 @@ class ExtractBurnin(openpype.api.Extractor): "fusion", "aftereffects", "tvpaint", - "webpublisher" + "webpublisher", + "aftereffects" # "resolve" ] optional = True diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 07e40b0421..d4d3cec1b1 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -45,7 +45,8 @@ class ExtractReview(pyblish.api.InstancePlugin): "fusion", "tvpaint", "resolve", - "webpublisher" + "webpublisher", + "aftereffects" ] # Supported extensions diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 3504206fe1..67e9f9ca19 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -97,7 +97,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "background", "camerarig", "redshiftproxy", - "effect" + "effect", + "xgen" ] exclude_families = ["clip"] db_representation_context_keys = [ diff --git a/openpype/plugins/publish/validate_editorial_asset_name.py b/openpype/plugins/publish/validate_editorial_asset_name.py index f13e3b4f38..eebba61af3 100644 --- a/openpype/plugins/publish/validate_editorial_asset_name.py +++ b/openpype/plugins/publish/validate_editorial_asset_name.py @@ -16,6 +16,7 @@ class ValidateEditorialAssetName(pyblish.api.ContextPlugin): def process(self, context): asset_and_parents = self.get_parents(context) + self.log.debug("__ asset_and_parents: {}".format(asset_and_parents)) if not io.Session: io.install() @@ -25,7 +26,8 @@ class ValidateEditorialAssetName(pyblish.api.ContextPlugin): self.log.debug("__ db_assets: {}".format(db_assets)) asset_db_docs = { - str(e["name"]): e["data"]["parents"] for e in db_assets} + str(e["name"]): e["data"]["parents"] + for e in db_assets} self.log.debug("__ project_entities: {}".format( pformat(asset_db_docs))) @@ -107,6 +109,7 @@ class ValidateEditorialAssetName(pyblish.api.ContextPlugin): parents = instance.data["parents"] return_dict.update({ - asset: [p["entity_name"] for p in parents] + asset: [p["entity_name"] for p in parents + if p["entity_type"].lower() != "project"] }) return return_dict diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 2dba20d63c..9fb964b494 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -1,4 +1,5 @@ { + "deadline_servers": [], "publish": { "ValidateExpectedFiles": { "enabled": true, @@ -11,6 +12,30 @@ "deadline" ] }, + "ProcessSubmittedJobOnFarm": { + "enabled": true, + "deadline_department": "", + "deadline_pool": "", + "deadline_group": "", + "deadline_chunk_size": 1, + "deadline_priority": 50, + "publishing_script": "", + "skip_integration_repre_list": [], + "aov_filter": { + "maya": [ + ".+(?:\\.|_)([Bb]eauty)(?:\\.|_).*" + ], + "nuke": [ + ".*" + ], + "aftereffects": [ + ".*" + ], + "celaction": [ + ".*" + ] + } + }, "MayaSubmitDeadline": { "enabled": true, "optional": false, @@ -21,7 +46,8 @@ "group": "none", "limit": [], "jobInfo": {}, - "pluginInfo": {} + "pluginInfo": {}, + "scene_patches": [] }, "NukeSubmitDeadline": { "enabled": true, diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index dae5a591e9..692176a585 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -298,6 +298,18 @@ "add_ftrack_family": true } ] + }, + { + "hosts": [ + "aftereffects" + ], + "families": [ + "render", + "workfile" + ], + "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 c14486f384..aab8c2196c 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -173,28 +173,6 @@ } ] }, - "ProcessSubmittedJobOnFarm": { - "enabled": true, - "deadline_department": "", - "deadline_pool": "", - "deadline_group": "", - "deadline_chunk_size": 1, - "deadline_priority": 50, - "publishing_script": "", - "skip_integration_repre_list": [], - "aov_filter": { - "maya": [ - ".+(?:\\.|_)([Bb]eauty)(?:\\.|_).*" - ], - "nuke": [], - "aftereffects": [ - ".*" - ], - "celaction": [ - ".*" - ] - } - }, "CleanUp": { "paterns": [], "remove_temp_renders": false @@ -257,6 +235,16 @@ ], "tasks": [], "template": "{family}{Task}" + }, + { + "families": [ + "renderLocal" + ], + "hosts": [ + "aftereffects" + ], + "tasks": [], + "template": "render{Task}{Variant}" } ] }, diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 1db6cdf9f1..f9911897d7 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -7,6 +7,19 @@ "workfile": "ma", "yetiRig": "ma" }, + "maya-dirmap": { + "enabled": true, + "paths": { + "source-path": [ + "foo1", + "foo2" + ], + "destination-path": [ + "bar1", + "bar2" + ] + } + }, "scriptsmenu": { "name": "OpenPype Tools", "definition": [ @@ -31,6 +44,12 @@ "Main" ] }, + "CreateRender": { + "enabled": true, + "defaults": [ + "Main" + ] + }, "CreateAnimation": { "enabled": true, "defaults": [ @@ -81,12 +100,6 @@ "Main" ] }, - "CreateRender": { - "enabled": true, - "defaults": [ - "Main" - ] - }, "CreateRenderSetup": { "enabled": true, "defaults": [ diff --git a/openpype/settings/defaults/project_settings/unreal.json b/openpype/settings/defaults/project_settings/unreal.json index 46b9ca2a18..dad61cd1f0 100644 --- a/openpype/settings/defaults/project_settings/unreal.json +++ b/openpype/settings/defaults/project_settings/unreal.json @@ -1,6 +1,5 @@ { "project_setup": { - "dev_mode": true, - "install_unreal_python_engine": false + "dev_mode": true } } \ No newline at end of file diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index 3f9b098a96..1005f8d16b 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -140,7 +140,9 @@ }, "deadline": { "enabled": true, - "DEADLINE_REST_URL": "http://localhost:8082" + "deadline_urls": { + "default": "http://127.0.0.1:8082" + } }, "muster": { "enabled": false, diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index c0eef15e69..9cda702e9a 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -105,7 +105,8 @@ from .enum_entity import ( AppsEnumEntity, ToolsEnumEntity, TaskTypeEnumEntity, - ProvidersEnum + ProvidersEnum, + DeadlineUrlEnumEntity ) from .list_entity import ListEntity @@ -160,6 +161,7 @@ __all__ = ( "ToolsEnumEntity", "TaskTypeEnumEntity", "ProvidersEnum", + "DeadlineUrlEnumEntity", "ListEntity", diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index b4ebe885f5..851684520b 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -174,6 +174,14 @@ class BaseItemEntity(BaseEntity): roles = [roles] self.roles = roles + @abstractmethod + def collect_static_entities_by_path(self): + """Collect all paths of all static path entities. + + Static path is entity which is not dynamic or under dynamic entity. + """ + pass + @property def require_restart_on_change(self): return self._require_restart_on_change diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index d275d8ac3d..988464d059 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -141,6 +141,7 @@ class DictConditionalEntity(ItemEntity): self.enum_key = self.schema_data.get("enum_key") self.enum_label = self.schema_data.get("enum_label") self.enum_children = self.schema_data.get("enum_children") + self.enum_default = self.schema_data.get("enum_default") self.enum_entity = None @@ -277,15 +278,22 @@ class DictConditionalEntity(ItemEntity): if isinstance(item, dict) and "key" in item: valid_enum_items.append(item) + enum_keys = [] enum_items = [] for item in valid_enum_items: item_key = item["key"] + enum_keys.append(item_key) item_label = item.get("label") or item_key enum_items.append({item_key: item_label}) if not enum_items: return + if self.enum_default in enum_keys: + default_key = self.enum_default + else: + default_key = enum_keys[0] + # Create Enum child first enum_key = self.enum_key or "invalid" enum_schema = { @@ -293,7 +301,8 @@ class DictConditionalEntity(ItemEntity): "multiselection": False, "enum_items": enum_items, "key": enum_key, - "label": self.enum_label + "label": self.enum_label, + "default": default_key } enum_entity = self.create_schema_object(enum_schema, self) @@ -318,6 +327,11 @@ class DictConditionalEntity(ItemEntity): self.non_gui_children[item_key][child_obj.key] = child_obj + def collect_static_entities_by_path(self): + if self.is_dynamic_item or self.is_in_dynamic_item: + return {} + return {self.path: self} + def get_child_path(self, child_obj): """Get hierarchical path of child entity. diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py index bde5304787..73b08f101a 100644 --- a/openpype/settings/entities/dict_immutable_keys_entity.py +++ b/openpype/settings/entities/dict_immutable_keys_entity.py @@ -203,6 +203,18 @@ class DictImmutableKeysEntity(ItemEntity): ) self.show_borders = self.schema_data.get("show_borders", True) + def collect_static_entities_by_path(self): + output = {} + if self.is_dynamic_item or self.is_in_dynamic_item: + return output + + output[self.path] = self + for children in self.non_gui_children.values(): + result = children.collect_static_entities_by_path() + if result: + output.update(result) + return output + def get_child_path(self, child_obj): """Get hierarchical path of child entity. diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 4f6a2886bc..917e376904 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -73,21 +73,41 @@ class EnumEntity(BaseEnumEntity): def _item_initalization(self): self.multiselection = self.schema_data.get("multiselection", False) self.enum_items = self.schema_data.get("enum_items") + # Default is optional and non breaking attribute + enum_default = self.schema_data.get("default") - valid_keys = set() + all_keys = [] for item in self.enum_items or []: - valid_keys.add(tuple(item.keys())[0]) + key = tuple(item.keys())[0] + all_keys.append(key) - self.valid_keys = valid_keys + self.valid_keys = set(all_keys) if self.multiselection: self.valid_value_types = (list, ) - self.value_on_not_set = [] + value_on_not_set = [] + if enum_default: + if not isinstance(enum_default, list): + enum_default = [enum_default] + + for item in enum_default: + if item in all_keys: + value_on_not_set.append(item) + + self.value_on_not_set = value_on_not_set + else: - for key in valid_keys: - if self.value_on_not_set is NOT_SET: - self.value_on_not_set = key - break + if isinstance(enum_default, list) and enum_default: + enum_default = enum_default[0] + + if enum_default in self.valid_keys: + self.value_on_not_set = enum_default + + else: + for key in all_keys: + if self.value_on_not_set is NOT_SET: + self.value_on_not_set = key + break self.valid_value_types = (STRING_TYPE, ) @@ -423,3 +443,54 @@ class ProvidersEnum(BaseEnumEntity): self._current_value = value_on_not_set self.value_on_not_set = value_on_not_set + + +class DeadlineUrlEnumEntity(BaseEnumEntity): + schema_types = ["deadline_url-enum"] + + def _item_initalization(self): + self.multiselection = self.schema_data.get("multiselection", True) + + self.enum_items = [] + self.valid_keys = set() + + if self.multiselection: + self.valid_value_types = (list,) + self.value_on_not_set = [] + else: + for key in self.valid_keys: + if self.value_on_not_set is NOT_SET: + self.value_on_not_set = key + break + + self.valid_value_types = (STRING_TYPE,) + + # GUI attribute + self.placeholder = self.schema_data.get("placeholder") + + def _get_enum_values(self): + system_settings_entity = self.get_entity_from_path("system_settings") + + valid_keys = set() + enum_items_list = [] + deadline_urls_entity = ( + system_settings_entity + ["modules"] + ["deadline"] + ["deadline_urls"] + ) + for server_name, url_entity in deadline_urls_entity.items(): + enum_items_list.append( + {server_name: "{}: {}".format(server_name, url_entity.value)}) + valid_keys.add(server_name) + return enum_items_list, valid_keys + + def set_override_state(self, *args, **kwargs): + super(DeadlineUrlEnumEntity, 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 diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 6952529963..336d1f5c1e 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -53,6 +53,11 @@ class EndpointEntity(ItemEntity): def _settings_value(self): pass + def collect_static_entities_by_path(self): + if self.is_dynamic_item or self.is_in_dynamic_item: + return {} + return {self.path: self} + def settings_value(self): if self._override_state is OverrideState.NOT_DEFINED: return NOT_SET diff --git a/openpype/settings/entities/item_entities.py b/openpype/settings/entities/item_entities.py index 7e84f8c801..ac6b3e76dd 100644 --- a/openpype/settings/entities/item_entities.py +++ b/openpype/settings/entities/item_entities.py @@ -106,6 +106,9 @@ class PathEntity(ItemEntity): self.valid_value_types = valid_value_types self.child_obj = self.create_schema_object(item_schema, self) + def collect_static_entities_by_path(self): + return self.child_obj.collect_static_entities_by_path() + def get_child_path(self, _child_obj): return self.path @@ -192,6 +195,24 @@ class PathEntity(ItemEntity): class ListStrictEntity(ItemEntity): schema_types = ["list-strict"] + def __getitem__(self, idx): + if not isinstance(idx, int): + idx = int(idx) + return self.children[idx] + + def __setitem__(self, idx, value): + if not isinstance(idx, int): + idx = int(idx) + self.children[idx].set(value) + + def get(self, idx, default=None): + if not isinstance(idx, int): + idx = int(idx) + + if idx < len(self.children): + return self.children[idx] + return default + def _item_initalization(self): self.valid_value_types = (list, ) self.require_key = True @@ -222,6 +243,18 @@ class ListStrictEntity(ItemEntity): super(ListStrictEntity, self).schema_validations() + def collect_static_entities_by_path(self): + output = {} + if self.is_dynamic_item or self.is_in_dynamic_item: + return output + + output[self.path] = self + for child_obj in self.children: + result = child_obj.collect_static_entities_by_path() + if result: + output.update(result) + return output + def get_child_path(self, child_obj): result_idx = None for idx, _child_obj in enumerate(self.children): diff --git a/openpype/settings/entities/lib.py b/openpype/settings/entities/lib.py index e58281644a..01f61d8bdf 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 contextlib from .exceptions import ( SchemaTemplateMissingKeys, @@ -111,6 +112,10 @@ class SchemasHub: self._loaded_templates = {} self._loaded_schemas = {} + # 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() @@ -126,6 +131,60 @@ class SchemasHub: def gui_types(self): return self._gui_types + def get_template_name(self, item_def, default=None): + """Get template name from passed item definition. + + Args: + item_def(dict): Definition of item with "type". + default(object): Default return value. + """ + output = default + if not item_def or not isinstance(item_def, dict): + return output + + item_type = item_def.get("type") + if item_type in ("template", "schema_template"): + output = item_def["name"] + return output + + def is_dynamic_template_validating(self, template_name): + """Is template validating using different entity. + + Returns: + bool: Is template validating. + """ + if template_name in self._validating_dynamic: + return True + return False + + def is_dynamic_template_validated(self, template_name): + """Is template already validated. + + Returns: + bool: Is template validated. + """ + + if template_name in self._validated_dynamic: + return True + return False + + @contextlib.contextmanager + def validating_dynamic(self, template_name): + """Template name is validating and validated. + + Context manager that cares about storing template name validations of + template. + + This is to avoid infinite loop of dynamic children validation. + """ + self._validating_dynamic.add(template_name) + try: + yield + self._validated_dynamic.add(template_name) + + finally: + self._validating_dynamic.remove(template_name) + def get_schema(self, schema_name): """Get schema definition data by it's name. diff --git a/openpype/settings/entities/list_entity.py b/openpype/settings/entities/list_entity.py index 64bbad28a7..b06f4d7a2e 100644 --- a/openpype/settings/entities/list_entity.py +++ b/openpype/settings/entities/list_entity.py @@ -45,6 +45,24 @@ class ListEntity(EndpointEntity): return True return False + def __getitem__(self, idx): + if not isinstance(idx, int): + idx = int(idx) + return self.children[idx] + + def __setitem__(self, idx, value): + if not isinstance(idx, int): + idx = int(idx) + self.children[idx].set(value) + + def get(self, idx, default=None): + if not isinstance(idx, int): + idx = int(idx) + + if idx < len(self.children): + return self.children[idx] + return default + def index(self, item): if isinstance(item, BaseEntity): for idx, child_entity in enumerate(self.children): @@ -141,7 +159,20 @@ class ListEntity(EndpointEntity): item_schema = self.schema_data["object_type"] if not isinstance(item_schema, dict): item_schema = {"type": item_schema} - self.item_schema = item_schema + + obj_template_name = self.schema_hub.get_template_name(item_schema) + _item_schemas = self.schema_hub.resolve_schema_data(item_schema) + if len(_item_schemas) == 1: + self.item_schema = _item_schemas[0] + if self.item_schema != item_schema: + if "label" in self.item_schema: + self.item_schema.pop("label") + self.item_schema["use_label_wrap"] = False + else: + self.item_schema = _item_schemas + + # Store if was used template or schema + self._obj_template_name = obj_template_name if self.group_item is None: self.is_group = True @@ -150,6 +181,12 @@ class ListEntity(EndpointEntity): self.initial_value = [] def schema_validations(self): + if isinstance(self.item_schema, list): + reason = ( + "`ListWidget` has multiple items as object type." + ) + raise EntitySchemaError(self, reason) + super(ListEntity, self).schema_validations() if self.is_dynamic_item and self.use_label_wrap: @@ -167,18 +204,36 @@ class ListEntity(EndpointEntity): raise EntitySchemaError(self, reason) # Validate object type schema - child_validated = False + validate_children = True for child_entity in self.children: child_entity.schema_validations() - child_validated = True + validate_children = False break - if not child_validated: + if validate_children and self._obj_template_name: + _validated = self.schema_hub.is_dynamic_template_validated( + self._obj_template_name + ) + _validating = self.schema_hub.is_dynamic_template_validating( + self._obj_template_name + ) + validate_children = not _validated and not _validating + + if not validate_children: + return + + def _validate(): idx = 0 tmp_child = self._add_new_item(idx) tmp_child.schema_validations() self.children.pop(idx) + if self._obj_template_name: + with self.schema_hub.validating_dynamic(self._obj_template_name): + _validate() + else: + _validate() + def get_child_path(self, child_obj): result_idx = None for idx, _child_obj in enumerate(self.children): diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index 00677480e8..4a06d2d591 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -242,6 +242,14 @@ class RootEntity(BaseItemEntity): """Whan any children has changed.""" self.on_change() + def collect_static_entities_by_path(self): + output = {} + for child_obj in self.non_gui_children.values(): + result = child_obj.collect_static_entities_by_path() + if result: + output.update(result) + return output + def get_child_path(self, child_entity): """Return path of children entity""" for key, _child_entity in self.non_gui_children.items(): diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index 399c4ac1d9..2034d4e463 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -195,6 +195,7 @@ - all items in `enum_children` must have at least `key` key which represents value stored under `enum_key` - items can define `label` for UI purposes - most important part is that item can define `children` key where are definitions of it's children (`children` value works the same way as in `dict`) +- to set default value for `enum_key` set it with `enum_default` - entity must have defined `"label"` if is not used as widget - is set as group if any parent is not group - if `"label"` is entetered there which will be shown in GUI @@ -359,6 +360,9 @@ How output of the schema could look like on save: - values are defined under value of key `"enum_items"` as list - each item in list is simple dictionary where value is label and key is value which will be stored - should be possible to enter single dictionary if order of items doesn't matter +- it is possible to set default selected value/s with `default` attribute + - it is recommended to use this option only in single selection mode + - at the end this option is used only when defying default settings value or in dynamic items ``` { @@ -371,7 +375,7 @@ How output of the schema could look like on save: {"ftrackreview": "Add to Ftrack"}, {"delete": "Delete output"}, {"slate-frame": "Add slate frame"}, - {"no-hnadles": "Skip handle frames"} + {"no-handles": "Skip handle frames"} ] } ``` @@ -417,6 +421,8 @@ How output of the schema could look like on save: - there are 2 possible ways how to set the type: 1.) dictionary with item modifiers (`number` input has `minimum`, `maximum` and `decimals`) in that case item type must be set as value of `"type"` (example below) 2.) item type name as string without modifiers (e.g. `text`) + 3.) enhancement of 1.) there is also support of `template` type but be carefull about endless loop of templates + - goal of using `template` is to easily change same item definitions in multiple lists 1.) with item modifiers ``` @@ -442,6 +448,65 @@ How output of the schema could look like on save: } ``` +3.) with template definition +``` +# Schema of list item where template is used +{ + "type": "list", + "key": "menu_items", + "label": "Menu Items", + "object_type": { + "type": "template", + "name": "template_object_example" + } +} + +# WARNING: +# In this example the template use itself inside which will work in `list` +# but may cause an issue in other entity types (e.g. `dict`). + +'template_object_example.json' : +[ + { + "type": "dict-conditional", + "use_label_wrap": true, + "collapsible": true, + "key": "menu_items", + "label": "Menu items", + "enum_key": "type", + "enum_label": "Type", + "enum_children": [ + { + "key": "action", + "label": "Action", + "children": [ + { + "type": "text", + "key": "key", + "label": "Key" + } + ] + }, + { + "key": "menu", + "label": "Menu", + "children": [ + { + "key": "children", + "label": "Children", + "type": "list", + "object_type": { + "type": "template", + "name": "template_object_example" + } + } + ] + } + ] + } +] +``` + ### dict-modifiable - one of dictionary inputs, this is only used as value input - items in this input can be removed and added same way as in `list` input diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index 27eeaef559..eb9eeb5448 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -5,6 +5,12 @@ "collapsible": true, "is_file": true, "children": [ + { + "type": "deadline_url-enum", + "key": "deadline_servers", + "label": "Deadline Webservice URLs", + "multiselect": true + }, { "type": "dict", "collapsible": true, @@ -52,11 +58,106 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "ProcessSubmittedJobOnFarm", + "label": "ProcessSubmittedJobOnFarm", + "checkbox_key": "enabled", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "text", + "key": "deadline_department", + "label": "Deadline department" + }, + { + "type": "text", + "key": "deadline_pool", + "label": "Deadline Pool" + }, + { + "type": "text", + "key": "deadline_group", + "label": "Deadline Group" + }, + { + "type": "number", + "key": "deadline_chunk_size", + "label": "Deadline Chunk Size" + }, + { + "type": "number", + "key": "deadline_priority", + "label": "Deadline Priotity" + }, + { + "type": "splitter" + }, + { + "type": "text", + "key": "publishing_script", + "label": "Publishing script path" + }, + { + "type": "list", + "key": "skip_integration_repre_list", + "label": "Skip integration of representation with ext", + "object_type": { + "type": "text" + } + }, + { + "type": "dict", + "key": "aov_filter", + "label": "Reviewable subsets filter", + "children": [ + { + "type": "list", + "key": "maya", + "label": "Maya", + "object_type": { + "type": "text" + } + }, + { + "type": "list", + "key": "nuke", + "label": "Nuke", + "object_type": { + "type": "text" + } + }, + { + "type": "list", + "key": "aftereffects", + "label": "After Effects", + "object_type": { + "type": "text" + } + }, + { + "type": "list", + "key": "celaction", + "label": "Celaction", + "object_type": { + "type": "text" + } + } + ] + } + ] + }, { "type": "dict", "collapsible": true, "key": "MayaSubmitDeadline", - "label": "Submit maya job to deadline", + "label": "Submit Maya job to Deadline", "checkbox_key": "enabled", "children": [ { @@ -118,6 +219,31 @@ "type": "raw-json", "key": "pluginInfo", "label": "Additional PluginInfo data" + }, + { + "type": "list", + "key": "scene_patches", + "label": "Scene patches", + "required_keys": ["name", "regex", "line"], + "object_type": { + "type": "dict", + "children": [ + { + "key": "name", + "label": "Patch name", + "type": "text" + }, { + "key": "regex", + "label": "Patch regex", + "type": "text" + }, { + "key": "line", + "label": "Patch line", + "type": "text" + } + ] + + } } ] }, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json index c2a8274313..cc70516c72 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json @@ -14,6 +14,39 @@ "type": "text" } }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "maya-dirmap", + "label": "Maya Directory Mapping", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "dict", + "key": "paths", + "children": [ + { + "type": "list", + "object_type": "text", + "key": "source-path", + "label": "Source Path" + }, + { + "type": "list", + "object_type": "text", + "key": "destination-path", + "label": "Destination Path" + } + ] + } + ] + }, { "type": "schema", "name": "schema_maya_scriptsmenu" 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 a1cbc8639f..d265988534 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 @@ -556,101 +556,6 @@ } ] }, - { - "type": "dict", - "collapsible": true, - "key": "ProcessSubmittedJobOnFarm", - "label": "ProcessSubmittedJobOnFarm", - "checkbox_key": "enabled", - "is_group": true, - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, - { - "type": "text", - "key": "deadline_department", - "label": "Deadline department" - }, - { - "type": "text", - "key": "deadline_pool", - "label": "Deadline Pool" - }, - { - "type": "text", - "key": "deadline_group", - "label": "Deadline Group" - }, - { - "type": "number", - "key": "deadline_chunk_size", - "label": "Deadline Chunk Size" - }, - { - "type": "number", - "key": "deadline_priority", - "label": "Deadline Priotity" - }, - { - "type": "splitter" - }, - { - "type": "text", - "key": "publishing_script", - "label": "Publishing script path" - }, - { - "type": "list", - "key": "skip_integration_repre_list", - "label": "Skip integration of representation with ext", - "object_type": { - "type": "text" - } - }, - { - "type": "dict", - "key": "aov_filter", - "label": "Reviewable subsets filter", - "children": [ - { - "type": "list", - "key": "maya", - "label": "Maya", - "object_type": { - "type": "text" - } - }, - { - "type": "list", - "key": "nuke", - "label": "Nuke", - "object_type": { - "type": "text" - } - }, - { - "type": "list", - "key": "aftereffects", - "label": "After Effects", - "object_type": { - "type": "text" - } - }, - { - "type": "list", - "key": "celaction", - "label": "Celaction", - "object_type": { - "type": "text" - } - } - ] - } - ] - }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json index d728f1def3..44a35af7c1 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json @@ -29,6 +29,26 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CreateRender", + "label": "Create Render", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "defaults", + "label": "Default Subsets", + "object_type": "text" + } + ] + }, { "type": "schema_template", "name": "template_create_plugin", @@ -65,10 +85,6 @@ "key": "CreatePointCache", "label": "Create Cache" }, - { - "key": "CreateRender", - "label": "Create Render" - }, { "key": "CreateRenderSetup", "label": "Create Render Setup" diff --git a/openpype/settings/entities/schemas/system_schema/example_infinite_hierarchy.json b/openpype/settings/entities/schemas/system_schema/example_infinite_hierarchy.json new file mode 100644 index 0000000000..a2660e9bf2 --- /dev/null +++ b/openpype/settings/entities/schemas/system_schema/example_infinite_hierarchy.json @@ -0,0 +1,58 @@ +[ + { + "type": "dict-conditional", + "use_label_wrap": true, + "collapsible": true, + "key": "menu_items", + "label": "Menu items", + "enum_key": "type", + "enum_label": "Type", + "enum_children": [ + { + "key": "action", + "label": "Action", + "children": [ + { + "type": "text", + "key": "key", + "label": "Key" + }, + { + "type": "text", + "key": "label", + "label": "Label" + }, + { + "type": "text", + "key": "command", + "label": "Comand" + } + ] + }, + { + "key": "menu", + "label": "Menu", + "children": [ + { + "type": "text", + "key": "label", + "label": "Label" + }, + { + "key": "children", + "label": "Children", + "type": "list", + "object_type": { + "type": "template", + "name": "example_infinite_hierarchy" + } + } + ] + }, + { + "key": "separator", + "label": "Separator" + } + ] + } +] diff --git a/openpype/settings/entities/schemas/system_schema/example_schema.json b/openpype/settings/entities/schemas/system_schema/example_schema.json index 8ec97064a1..f633d5cb1a 100644 --- a/openpype/settings/entities/schemas/system_schema/example_schema.json +++ b/openpype/settings/entities/schemas/system_schema/example_schema.json @@ -82,6 +82,17 @@ } ] }, + { + "type": "list", + "use_label_wrap": true, + "collapsible": true, + "key": "infinite_hierarchy", + "label": "Infinite list template hierarchy", + "object_type": { + "type": "template", + "name": "example_infinite_hierarchy" + } + }, { "type": "dict", "key": "schema_template_exaples", diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index f82c3632a9..8cd729d2a1 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -130,9 +130,11 @@ "label": "Enabled" }, { - "type": "text", - "key": "DEADLINE_REST_URL", - "label": "Deadline Resl URL" + "type": "dict-modifiable", + "object_type": "text", + "key": "deadline_urls", + "required_keys": ["default"], + "label": "Deadline Webservice URLs" } ] }, diff --git a/openpype/style/__init__.py b/openpype/style/__init__.py index 89a210bee9..87547b1a90 100644 --- a/openpype/style/__init__.py +++ b/openpype/style/__init__.py @@ -65,6 +65,7 @@ def _load_font(): font_dirs = [] font_dirs.append(os.path.join(fonts_dirpath, "Montserrat")) font_dirs.append(os.path.join(fonts_dirpath, "Spartan")) + font_dirs.append(os.path.join(fonts_dirpath, "RobotoMono", "static")) loaded_fonts = [] for font_dir in font_dirs: diff --git a/openpype/style/fonts/RobotoMono/LICENSE.txt b/openpype/style/fonts/RobotoMono/LICENSE.txt new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/openpype/style/fonts/RobotoMono/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/openpype/style/fonts/RobotoMono/README.txt b/openpype/style/fonts/RobotoMono/README.txt new file mode 100644 index 0000000000..1bc1b1cfa2 --- /dev/null +++ b/openpype/style/fonts/RobotoMono/README.txt @@ -0,0 +1,77 @@ +Roboto Mono Variable Font +========================= + +This download contains Roboto Mono as both variable fonts and static fonts. + +Roboto Mono is a variable font with this axis: + wght + +This means all the styles are contained in these files: + RobotoMono-VariableFont_wght.ttf + RobotoMono-Italic-VariableFont_wght.ttf + +If your app fully supports variable fonts, you can now pick intermediate styles +that aren’t available as static fonts. Not all apps support variable fonts, and +in those cases you can use the static font files for Roboto Mono: + static/RobotoMono-Thin.ttf + static/RobotoMono-ExtraLight.ttf + static/RobotoMono-Light.ttf + static/RobotoMono-Regular.ttf + static/RobotoMono-Medium.ttf + static/RobotoMono-SemiBold.ttf + static/RobotoMono-Bold.ttf + static/RobotoMono-ThinItalic.ttf + static/RobotoMono-ExtraLightItalic.ttf + static/RobotoMono-LightItalic.ttf + static/RobotoMono-Italic.ttf + static/RobotoMono-MediumItalic.ttf + static/RobotoMono-SemiBoldItalic.ttf + static/RobotoMono-BoldItalic.ttf + +Get started +----------- + +1. Install the font files you want to use + +2. Use your app's font picker to view the font family and all the +available styles + +Learn more about variable fonts +------------------------------- + + https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts + https://variablefonts.typenetwork.com + https://medium.com/variable-fonts + +In desktop apps + + https://theblog.adobe.com/can-variable-fonts-illustrator-cc + https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts + +Online + + https://developers.google.com/fonts/docs/getting_started + https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide + https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts + +Installing fonts + + MacOS: https://support.apple.com/en-us/HT201749 + Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux + Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows + +Android Apps + + https://developers.google.com/fonts/docs/android + https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts + +License +------- +Please read the full license text (LICENSE.txt) to understand the permissions, +restrictions and requirements for usage, redistribution, and modification. + +You can use them freely in your products & projects - print or digital, +commercial or otherwise. + +This isn't legal advice, please consider consulting a lawyer and see the full +license for all details. diff --git a/openpype/style/fonts/RobotoMono/RobotoMono-Italic-VariableFont_wght.ttf b/openpype/style/fonts/RobotoMono/RobotoMono-Italic-VariableFont_wght.ttf new file mode 100644 index 0000000000..d30055a9e8 Binary files /dev/null and b/openpype/style/fonts/RobotoMono/RobotoMono-Italic-VariableFont_wght.ttf differ diff --git a/openpype/style/fonts/RobotoMono/RobotoMono-VariableFont_wght.ttf b/openpype/style/fonts/RobotoMono/RobotoMono-VariableFont_wght.ttf new file mode 100644 index 0000000000..d2b4746196 Binary files /dev/null and b/openpype/style/fonts/RobotoMono/RobotoMono-VariableFont_wght.ttf differ diff --git a/openpype/style/fonts/RobotoMono/static/RobotoMono-Bold.ttf b/openpype/style/fonts/RobotoMono/static/RobotoMono-Bold.ttf new file mode 100644 index 0000000000..900fce6848 Binary files /dev/null and b/openpype/style/fonts/RobotoMono/static/RobotoMono-Bold.ttf differ diff --git a/openpype/style/fonts/RobotoMono/static/RobotoMono-BoldItalic.ttf b/openpype/style/fonts/RobotoMono/static/RobotoMono-BoldItalic.ttf new file mode 100644 index 0000000000..4bfe29ae89 Binary files /dev/null and b/openpype/style/fonts/RobotoMono/static/RobotoMono-BoldItalic.ttf differ diff --git a/openpype/style/fonts/RobotoMono/static/RobotoMono-ExtraLight.ttf b/openpype/style/fonts/RobotoMono/static/RobotoMono-ExtraLight.ttf new file mode 100644 index 0000000000..d535884553 Binary files /dev/null and b/openpype/style/fonts/RobotoMono/static/RobotoMono-ExtraLight.ttf differ diff --git a/openpype/style/fonts/RobotoMono/static/RobotoMono-ExtraLightItalic.ttf b/openpype/style/fonts/RobotoMono/static/RobotoMono-ExtraLightItalic.ttf new file mode 100644 index 0000000000..b28960a0ee Binary files /dev/null and b/openpype/style/fonts/RobotoMono/static/RobotoMono-ExtraLightItalic.ttf differ diff --git a/openpype/style/fonts/RobotoMono/static/RobotoMono-Italic.ttf b/openpype/style/fonts/RobotoMono/static/RobotoMono-Italic.ttf new file mode 100644 index 0000000000..4ee4dc49b4 Binary files /dev/null and b/openpype/style/fonts/RobotoMono/static/RobotoMono-Italic.ttf differ diff --git a/openpype/style/fonts/RobotoMono/static/RobotoMono-Light.ttf b/openpype/style/fonts/RobotoMono/static/RobotoMono-Light.ttf new file mode 100644 index 0000000000..276af4c55a Binary files /dev/null and b/openpype/style/fonts/RobotoMono/static/RobotoMono-Light.ttf differ diff --git a/openpype/style/fonts/RobotoMono/static/RobotoMono-LightItalic.ttf b/openpype/style/fonts/RobotoMono/static/RobotoMono-LightItalic.ttf new file mode 100644 index 0000000000..a2801c2168 Binary files /dev/null and b/openpype/style/fonts/RobotoMono/static/RobotoMono-LightItalic.ttf differ diff --git a/openpype/style/fonts/RobotoMono/static/RobotoMono-Medium.ttf b/openpype/style/fonts/RobotoMono/static/RobotoMono-Medium.ttf new file mode 100644 index 0000000000..8461be77a3 Binary files /dev/null and b/openpype/style/fonts/RobotoMono/static/RobotoMono-Medium.ttf differ diff --git a/openpype/style/fonts/RobotoMono/static/RobotoMono-MediumItalic.ttf b/openpype/style/fonts/RobotoMono/static/RobotoMono-MediumItalic.ttf new file mode 100644 index 0000000000..a3bfaa115a Binary files /dev/null and b/openpype/style/fonts/RobotoMono/static/RobotoMono-MediumItalic.ttf differ diff --git a/openpype/style/fonts/RobotoMono/static/RobotoMono-Regular.ttf b/openpype/style/fonts/RobotoMono/static/RobotoMono-Regular.ttf new file mode 100644 index 0000000000..7c4ce36a44 Binary files /dev/null and b/openpype/style/fonts/RobotoMono/static/RobotoMono-Regular.ttf differ diff --git a/openpype/style/fonts/RobotoMono/static/RobotoMono-SemiBold.ttf b/openpype/style/fonts/RobotoMono/static/RobotoMono-SemiBold.ttf new file mode 100644 index 0000000000..15ee6c6e40 Binary files /dev/null and b/openpype/style/fonts/RobotoMono/static/RobotoMono-SemiBold.ttf differ diff --git a/openpype/style/fonts/RobotoMono/static/RobotoMono-SemiBoldItalic.ttf b/openpype/style/fonts/RobotoMono/static/RobotoMono-SemiBoldItalic.ttf new file mode 100644 index 0000000000..8e21497793 Binary files /dev/null and b/openpype/style/fonts/RobotoMono/static/RobotoMono-SemiBoldItalic.ttf differ diff --git a/openpype/style/fonts/RobotoMono/static/RobotoMono-Thin.ttf b/openpype/style/fonts/RobotoMono/static/RobotoMono-Thin.ttf new file mode 100644 index 0000000000..ee8a3fd41a Binary files /dev/null and b/openpype/style/fonts/RobotoMono/static/RobotoMono-Thin.ttf differ diff --git a/openpype/style/fonts/RobotoMono/static/RobotoMono-ThinItalic.ttf b/openpype/style/fonts/RobotoMono/static/RobotoMono-ThinItalic.ttf new file mode 100644 index 0000000000..40b01e40de Binary files /dev/null and b/openpype/style/fonts/RobotoMono/static/RobotoMono-ThinItalic.ttf differ diff --git a/openpype/style/style.css b/openpype/style/style.css index b955bdc2a6..830ed85f9b 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -271,37 +271,38 @@ QTabWidget::tab-bar { } QTabBar::tab { - border-top-left-radius: 4px; - border-top-right-radius: 4px; padding: 5px; - + border-left: 3px solid transparent; + border-top: 1px solid {color:border}; + border-right: 1px solid {color:border}; + background: qlineargradient( + x1: 0, y1: 1, x2: 0, y2: 0, + stop: 0.5 {color:bg}, stop: 1.0 {color:bg-inputs} + ); } QTabBar::tab:selected { background: {color:grey-lighter}; - /* background: qradialgradient( - cx:0.5, cy:0.5, radius: 2, - fx:0.5, fy:1, - stop:0.3 {color:bg}, stop:1 white - ) */ - /* background: qlineargradient( - x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 {color:bg-inputs}, stop: 1.0 {color:bg} - ); */ + border-left: 3px solid {color:border-focus}; + background: qlineargradient( + x1: 0, y1: 1, x2: 0, y2: 0, + stop: 0.5 {color:bg}, stop: 1.0 {color:border} + ); } QTabBar::tab:!selected { - /* Make it smaller*/ - margin-top: 3px; background: {color:grey-light}; } QTabBar::tab:!selected:hover { background: {color:grey-lighter}; } - +QTabBar::tab:first { + border-left: 1px solid {color:border}; +} QTabBar::tab:first:selected { margin-left: 0; + border-left: 3px solid {color:border-focus}; } QTabBar::tab:last:selected { @@ -623,3 +624,8 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { border: 1px solid {color:border}; border-radius: 0.1em; } + +/* Python console interpreter */ +#PythonInterpreterOutput, #PythonCodeEditor { + font-family: "Roboto Mono"; +} diff --git a/openpype/tools/mayalookassigner/commands.py b/openpype/tools/mayalookassigner/commands.py index 2add5d3499..a53251cdef 100644 --- a/openpype/tools/mayalookassigner/commands.py +++ b/openpype/tools/mayalookassigner/commands.py @@ -103,12 +103,19 @@ def create_asset_id_hash(nodes): """ node_id_hash = defaultdict(list) for node in nodes: - value = lib.get_id(node) - if value is None: - continue + # iterate over content of reference node + if cmds.nodeType(node) == "reference": + ref_hashes = create_asset_id_hash( + cmds.referenceQuery(node, nodes=True)) + for asset_id, ref_nodes in ref_hashes.items(): + node_id_hash[asset_id] += ref_nodes + else: + value = lib.get_id(node) + if value is None: + continue - asset_id = value.split(":")[0] - node_id_hash[asset_id].append(node) + asset_id = value.split(":")[0] + node_id_hash[asset_id].append(node) return dict(node_id_hash) @@ -135,18 +142,19 @@ def create_items_from_nodes(nodes): id_hashes = create_asset_id_hash(nodes) # get ids from alembic - vray_proxy_nodes = cmds.ls(nodes, type="VRayProxy") - for vp in vray_proxy_nodes: - path = cmds.getAttr("{}.fileName".format(vp)) - ids = vray_proxies.get_alembic_ids_cache(path) - parent_id = {} - for k, _ in ids.items(): - pid = k.split(":")[0] - if not parent_id.get(pid): - parent_id.update({pid: [vp]}) + if cmds.pluginInfo('vrayformaya', query=True, loaded=True): + vray_proxy_nodes = cmds.ls(nodes, type="VRayProxy") + for vp in vray_proxy_nodes: + path = cmds.getAttr("{}.fileName".format(vp)) + ids = vray_proxies.get_alembic_ids_cache(path) + parent_id = {} + for k, _ in ids.items(): + pid = k.split(":")[0] + if not parent_id.get(pid): + parent_id.update({pid: [vp]}) - print("Adding ids from alembic {}".format(path)) - id_hashes.update(parent_id) + print("Adding ids from alembic {}".format(path)) + id_hashes.update(parent_id) if not id_hashes: return asset_view_items diff --git a/openpype/tools/pyblish_pype/control.py b/openpype/tools/pyblish_pype/control.py index ae9ca40be5..234135fd9a 100644 --- a/openpype/tools/pyblish_pype/control.py +++ b/openpype/tools/pyblish_pype/control.py @@ -316,6 +316,7 @@ class Controller(QtCore.QObject): self.was_skipped.emit(plugin) continue + in_collect_stage = self.collect_state == 0 if plugin.__instanceEnabled__: instances = pyblish.logic.instances_by_plugin( self.context, plugin @@ -325,7 +326,10 @@ class Controller(QtCore.QObject): continue for instance in instances: - if instance.data.get("publish") is False: + if ( + not in_collect_stage + and instance.data.get("publish") is False + ): pyblish.logic.log.debug( "%s was inactive, skipping.." % instance ) @@ -338,7 +342,7 @@ class Controller(QtCore.QObject): yield (plugin, instance) else: families = util.collect_families_from_instances( - self.context, only_active=True + self.context, only_active=not in_collect_stage ) plugins = pyblish.logic.plugins_by_families( [plugin], families diff --git a/openpype/tools/pyblish_pype/model.py b/openpype/tools/pyblish_pype/model.py index 50ba27166b..bb1aff2a9a 100644 --- a/openpype/tools/pyblish_pype/model.py +++ b/openpype/tools/pyblish_pype/model.py @@ -498,6 +498,9 @@ class PluginModel(QtGui.QStandardItemModel): ): new_flag_states[PluginStates.HasError] = True + if not publish_states & PluginStates.IsCompatible: + new_flag_states[PluginStates.IsCompatible] = True + item.setData(new_flag_states, Roles.PublishFlagsRole) records = item.data(Roles.LogRecordsRole) or [] diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index eb5f82ab9a..8235cf8642 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -25,6 +25,38 @@ class BaseWidget(QtWidgets.QWidget): self.label_widget = None self.create_ui() + def scroll_to(self, widget): + self.category_widget.scroll_to(widget) + + def set_path(self, path): + self.category_widget.set_path(path) + + def set_focus(self, scroll_to=False): + """Set focus of a widget. + + Args: + scroll_to(bool): Also scroll to widget in category widget. + """ + if scroll_to: + self.scroll_to(self) + self.setFocus() + + def make_sure_is_visible(self, path, scroll_to): + """Make a widget of entity visible by it's path. + + Args: + path(str): Path to entity. + scroll_to(bool): Should be scrolled to entity. + + Returns: + bool: Entity with path was found. + """ + raise NotImplementedError( + "{} not implemented `make_sure_is_visible`".format( + self.__class__.__name__ + ) + ) + def trigger_hierarchical_style_update(self): self.category_widget.hierarchical_style_update() @@ -277,11 +309,23 @@ class BaseWidget(QtWidgets.QWidget): if to_run: to_run() + def focused_in(self): + if self.entity is not None: + self.set_path(self.entity.path) + def mouseReleaseEvent(self, event): if self.allow_actions and event.button() == QtCore.Qt.RightButton: return self.show_actions_menu() - return super(BaseWidget, self).mouseReleaseEvent(event) + focused_in = False + if event.button() == QtCore.Qt.LeftButton: + focused_in = True + self.focused_in() + + result = super(BaseWidget, self).mouseReleaseEvent(event) + if focused_in and not event.isAccepted(): + event.accept() + return result class InputWidget(BaseWidget): @@ -337,6 +381,14 @@ class InputWidget(BaseWidget): ) ) + def make_sure_is_visible(self, path, scroll_to): + if path: + entity_path = self.entity.path + if entity_path == path: + self.set_focus(scroll_to) + return True + return False + def update_style(self): has_unsaved_changes = self.entity.has_unsaved_changes if not has_unsaved_changes and self.entity.group_item: @@ -422,11 +474,20 @@ class GUIWidget(BaseWidget): layout.addWidget(splitter_item) def set_entity_value(self): - return + pass def hierarchical_style_update(self): pass + def make_sure_is_visible(self, *args, **kwargs): + return False + + def focused_in(self): + pass + + def set_path(self, *args, **kwargs): + pass + def get_invalid(self): return [] diff --git a/openpype/tools/settings/settings/breadcrumbs_widget.py b/openpype/tools/settings/settings/breadcrumbs_widget.py new file mode 100644 index 0000000000..b625a7bb07 --- /dev/null +++ b/openpype/tools/settings/settings/breadcrumbs_widget.py @@ -0,0 +1,492 @@ +from Qt import QtWidgets, QtGui, QtCore + +PREFIX_ROLE = QtCore.Qt.UserRole + 1 +LAST_SEGMENT_ROLE = QtCore.Qt.UserRole + 2 + + +class BreadcrumbItem(QtGui.QStandardItem): + def __init__(self, *args, **kwargs): + self._display_value = None + self._edit_value = None + super(BreadcrumbItem, self).__init__(*args, **kwargs) + + def data(self, role=None): + if role == QtCore.Qt.DisplayRole: + return self._display_value + + if role == QtCore.Qt.EditRole: + return self._edit_value + + if role is None: + args = tuple() + else: + args = (role, ) + return super(BreadcrumbItem, self).data(*args) + + def setData(self, value, role): + if role == QtCore.Qt.DisplayRole: + self._display_value = value + return True + + if role == QtCore.Qt.EditRole: + self._edit_value = value + return True + + if role is None: + args = (value, ) + else: + args = (value, role) + return super(BreadcrumbItem, self).setData(*args) + + +class BreadcrumbsModel(QtGui.QStandardItemModel): + def __init__(self): + super(BreadcrumbsModel, self).__init__() + self.current_path = "" + + self.reset() + + def reset(self): + return + + +class SettingsBreadcrumbs(BreadcrumbsModel): + def __init__(self): + self.entity = None + + self.entities_by_path = {} + self.dynamic_paths = set() + + super(SettingsBreadcrumbs, self).__init__() + + def set_entity(self, entity): + self.entities_by_path = {} + self.dynamic_paths = set() + self.entity = entity + self.reset() + + def has_children(self, path): + for key in self.entities_by_path.keys(): + if key.startswith(path): + return True + return False + + def is_valid_path(self, path): + if not path: + return True + + path_items = path.split("/") + try: + entity = self.entity + for item in path_items: + entity = entity[item] + except Exception: + return False + return True + + +class SystemSettingsBreadcrumbs(SettingsBreadcrumbs): + def reset(self): + root_item = self.invisibleRootItem() + rows = root_item.rowCount() + if rows > 0: + root_item.removeRows(0, rows) + + if self.entity is None: + return + + entities_by_path = self.entity.collect_static_entities_by_path() + self.entities_by_path = entities_by_path + items = [] + for path in entities_by_path.keys(): + if not path: + continue + path_items = path.split("/") + value = path + label = path_items.pop(-1) + prefix = "/".join(path_items) + if prefix: + prefix += "/" + + item = QtGui.QStandardItem(value) + item.setData(label, LAST_SEGMENT_ROLE) + item.setData(prefix, PREFIX_ROLE) + + items.append(item) + + root_item.appendRows(items) + + +class ProjectSettingsBreadcrumbs(SettingsBreadcrumbs): + def reset(self): + root_item = self.invisibleRootItem() + rows = root_item.rowCount() + if rows > 0: + root_item.removeRows(0, rows) + + if self.entity is None: + return + + entities_by_path = self.entity.collect_static_entities_by_path() + self.entities_by_path = entities_by_path + items = [] + for path in entities_by_path.keys(): + if not path: + continue + path_items = path.split("/") + value = path + label = path_items.pop(-1) + prefix = "/".join(path_items) + if prefix: + prefix += "/" + + item = QtGui.QStandardItem(value) + item.setData(label, LAST_SEGMENT_ROLE) + item.setData(prefix, PREFIX_ROLE) + + items.append(item) + + root_item.appendRows(items) + + +class BreadcrumbsProxy(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(BreadcrumbsProxy, self).__init__(*args, **kwargs) + + self._current_path = "" + + def set_path_prefix(self, prefix): + path = prefix + if not prefix.endswith("/"): + path_items = path.split("/") + if len(path_items) == 1: + path = "" + else: + path_items.pop(-1) + path = "/".join(path_items) + "/" + + if path == self._current_path: + return + + self._current_path = prefix + + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent): + index = self.sourceModel().index(row, 0, parent) + prefix_path = index.data(PREFIX_ROLE) + return prefix_path == self._current_path + + +class BreadcrumbsHintMenu(QtWidgets.QMenu): + def __init__(self, model, path_prefix, parent): + super(BreadcrumbsHintMenu, self).__init__(parent) + + self._path_prefix = path_prefix + self._model = model + + def showEvent(self, event): + self.clear() + + self._model.set_path_prefix(self._path_prefix) + + row_count = self._model.rowCount() + if row_count == 0: + action = self.addAction("* Nothing") + action.setData(".") + else: + for row in range(self._model.rowCount()): + index = self._model.index(row, 0) + label = index.data(LAST_SEGMENT_ROLE) + value = index.data(QtCore.Qt.EditRole) + action = self.addAction(label) + action.setData(value) + + super(BreadcrumbsHintMenu, self).showEvent(event) + + +class ClickableWidget(QtWidgets.QWidget): + clicked = QtCore.Signal() + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.clicked.emit() + super(ClickableWidget, self).mouseReleaseEvent(event) + + +class BreadcrumbsPathInput(QtWidgets.QLineEdit): + cancelled = QtCore.Signal() + confirmed = QtCore.Signal() + + def __init__(self, model, proxy_model, parent): + super(BreadcrumbsPathInput, self).__init__(parent) + + self.setObjectName("BreadcrumbsPathInput") + + self.setFrame(False) + + completer = QtWidgets.QCompleter(self) + completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + completer.setModel(proxy_model) + + popup = completer.popup() + popup.setUniformItemSizes(True) + popup.setLayoutMode(QtWidgets.QListView.Batched) + + self.setCompleter(completer) + + completer.activated.connect(self._on_completer_activated) + self.textEdited.connect(self._on_text_change) + + self._completer = completer + self._model = model + self._proxy_model = proxy_model + + self._context_menu_visible = False + + def set_model(self, model): + self._model = model + + def event(self, event): + if ( + event.type() == QtCore.QEvent.KeyPress + and event.key() == QtCore.Qt.Key_Tab + ): + if self._model: + find_value = self.text() + "/" + if self._model.has_children(find_value): + self.insert("/") + else: + self._completer.popup().hide() + event.accept() + return True + + return super(BreadcrumbsPathInput, self).event(event) + + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key_Escape: + self.cancelled.emit() + return + + if event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): + self.confirmed.emit() + return + + super(BreadcrumbsPathInput, self).keyPressEvent(event) + + def focusOutEvent(self, event): + if not self._context_menu_visible: + self.cancelled.emit() + + self._context_menu_visible = False + super(BreadcrumbsPathInput, self).focusOutEvent(event) + + def contextMenuEvent(self, event): + self._context_menu_visible = True + super(BreadcrumbsPathInput, self).contextMenuEvent(event) + + def _on_completer_activated(self, path): + self.confirmed.emit() + + def _on_text_change(self, path): + self._proxy_model.set_path_prefix(path) + + +class BreadcrumbsButton(QtWidgets.QToolButton): + path_selected = QtCore.Signal(str) + + def __init__(self, path, model, parent): + super(BreadcrumbsButton, self).__init__(parent) + + self.setObjectName("BreadcrumbsButton") + + path_prefix = path + if path: + path_prefix += "/" + + self.setAutoRaise(True) + self.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup) + + self.setMouseTracking(True) + + if path: + self.setText(path.split("/")[-1]) + else: + self.setProperty("empty", "1") + + menu = BreadcrumbsHintMenu(model, path_prefix, self) + + self.setMenu(menu) + + # fixed size breadcrumbs + self.setMinimumSize(self.minimumSizeHint()) + size_policy = self.sizePolicy() + size_policy.setVerticalPolicy(size_policy.Minimum) + self.setSizePolicy(size_policy) + + menu.triggered.connect(self._on_menu_click) + self.clicked.connect(self._on_click) + + self._path = path + self._path_prefix = path_prefix + self._model = model + self._menu = menu + + def _on_click(self): + self.path_selected.emit(self._path) + + def _on_menu_click(self, action): + item = action.data() + self.path_selected.emit(item) + + +class BreadcrumbsAddressBar(QtWidgets.QFrame): + "Windows Explorer-like address bar" + path_changed = QtCore.Signal(str) + path_edited = QtCore.Signal(str) + + def __init__(self, parent=None): + super(BreadcrumbsAddressBar, self).__init__(parent) + + self.setAutoFillBackground(True) + self.setFrameShape(self.StyledPanel) + + # Edit presented path textually + proxy_model = BreadcrumbsProxy() + path_input = BreadcrumbsPathInput(None, proxy_model, self) + path_input.setVisible(False) + + path_input.cancelled.connect(self._on_input_cancel) + path_input.confirmed.connect(self._on_input_confirm) + + # Container for `crumbs_panel` + crumbs_container = QtWidgets.QWidget(self) + + # Container for breadcrumbs + crumbs_panel = QtWidgets.QWidget(crumbs_container) + crumbs_panel.setObjectName("BreadcrumbsPanel") + + crumbs_layout = QtWidgets.QHBoxLayout() + crumbs_layout.setContentsMargins(0, 0, 0, 0) + crumbs_layout.setSpacing(0) + + crumbs_cont_layout = QtWidgets.QHBoxLayout(crumbs_container) + crumbs_cont_layout.setContentsMargins(0, 0, 0, 0) + crumbs_cont_layout.setSpacing(0) + crumbs_cont_layout.addWidget(crumbs_panel) + + # Clicking on empty space to the right puts the bar into edit mode + switch_space = ClickableWidget(self) + + crumb_panel_layout = QtWidgets.QHBoxLayout(crumbs_panel) + crumb_panel_layout.setContentsMargins(0, 0, 0, 0) + crumb_panel_layout.setSpacing(0) + crumb_panel_layout.addLayout(crumbs_layout, 0) + crumb_panel_layout.addWidget(switch_space, 1) + + switch_space.clicked.connect(self.switch_space_mouse_up) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(path_input) + layout.addWidget(crumbs_container) + + self.setMaximumHeight(path_input.height()) + + self.crumbs_layout = crumbs_layout + self.crumbs_panel = crumbs_panel + self.switch_space = switch_space + self.path_input = path_input + self.crumbs_container = crumbs_container + + self._model = None + self._proxy_model = proxy_model + + self._current_path = None + + def set_model(self, model): + self._model = model + self.path_input.set_model(model) + self._proxy_model.setSourceModel(model) + + def _on_input_confirm(self): + self.change_path(self.path_input.text()) + + def _on_input_cancel(self): + self._cancel_edit() + + def _clear_crumbs(self): + while self.crumbs_layout.count(): + widget = self.crumbs_layout.takeAt(0).widget() + if widget: + widget.deleteLater() + + def _insert_crumb(self, path): + btn = BreadcrumbsButton(path, self._proxy_model, self.crumbs_panel) + + self.crumbs_layout.insertWidget(0, btn) + + btn.path_selected.connect(self._on_crumb_clicked) + + def _on_crumb_clicked(self, path): + "Breadcrumb was clicked" + self.change_path(path) + + def change_path(self, path): + if self._model and not self._model.is_valid_path(path): + self._show_address_field() + else: + self.set_path(path) + self.path_edited.emit(path) + + def set_path(self, path): + if path is None or path == ".": + path = self._current_path + + # exit edit mode + self._cancel_edit() + + self._clear_crumbs() + self._current_path = path + self.path_input.setText(path) + path_items = [ + item + for item in path.split("/") + if item + ] + while path_items: + item = "/".join(path_items) + self._insert_crumb(item) + path_items.pop(-1) + self._insert_crumb("") + + self.path_changed.emit(self._current_path) + + def _cancel_edit(self): + "Set edit line text back to current path and switch to view mode" + # revert path + self.path_input.setText(self.path()) + # switch back to breadcrumbs view + self._show_address_field(False) + + def path(self): + "Get path displayed in this BreadcrumbsAddressBar" + return self._current_path + + def switch_space_mouse_up(self): + "EVENT: switch_space mouse clicked" + self._show_address_field(True) + + def _show_address_field(self, show=True): + "Show text address field" + self.crumbs_container.setVisible(not show) + self.path_input.setVisible(show) + if show: + self.path_input.setFocus() + self.path_input.selectAll() + + def minimumSizeHint(self): + result = super(BreadcrumbsAddressBar, self).minimumSizeHint() + result.setHeight(self.path_input.minimumSizeHint().height()) + return result diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index 8be3eddfa8..d1babd7fdb 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -31,6 +31,11 @@ from openpype.settings.entities import ( from openpype.settings import SaveWarningExc from .widgets import ProjectListWidget +from .breadcrumbs_widget import ( + BreadcrumbsAddressBar, + SystemSettingsBreadcrumbs, + ProjectSettingsBreadcrumbs +) from .base import GUIWidget from .list_item_widget import ListWidget @@ -175,6 +180,16 @@ class SettingsCategoryWidget(QtWidgets.QWidget): scroll_widget = QtWidgets.QScrollArea(self) scroll_widget.setObjectName("GroupWidget") content_widget = QtWidgets.QWidget(scroll_widget) + + breadcrumbs_label = QtWidgets.QLabel("Path:", content_widget) + breadcrumbs_widget = BreadcrumbsAddressBar(content_widget) + + breadcrumbs_layout = QtWidgets.QHBoxLayout() + breadcrumbs_layout.setContentsMargins(5, 5, 5, 5) + breadcrumbs_layout.setSpacing(5) + breadcrumbs_layout.addWidget(breadcrumbs_label) + breadcrumbs_layout.addWidget(breadcrumbs_widget) + content_layout = QtWidgets.QVBoxLayout(content_widget) content_layout.setContentsMargins(3, 3, 3, 3) content_layout.setSpacing(5) @@ -183,40 +198,43 @@ class SettingsCategoryWidget(QtWidgets.QWidget): scroll_widget.setWidgetResizable(True) scroll_widget.setWidget(content_widget) - configurations_widget = QtWidgets.QWidget(self) - - footer_widget = QtWidgets.QWidget(configurations_widget) - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - refresh_icon = qtawesome.icon("fa.refresh", color="white") - refresh_btn = QtWidgets.QPushButton(footer_widget) + refresh_btn = QtWidgets.QPushButton(self) refresh_btn.setIcon(refresh_icon) - footer_layout.addWidget(refresh_btn, 0) - + footer_layout = QtWidgets.QHBoxLayout() if self.user_role == "developer": self._add_developer_ui(footer_layout) - save_btn = QtWidgets.QPushButton("Save", footer_widget) - require_restart_label = QtWidgets.QLabel(footer_widget) + save_btn = QtWidgets.QPushButton("Save", self) + require_restart_label = QtWidgets.QLabel(self) require_restart_label.setAlignment(QtCore.Qt.AlignCenter) + + footer_layout.addWidget(refresh_btn, 0) footer_layout.addWidget(require_restart_label, 1) footer_layout.addWidget(save_btn, 0) - configurations_layout = QtWidgets.QVBoxLayout(configurations_widget) + configurations_layout = QtWidgets.QVBoxLayout() configurations_layout.setContentsMargins(0, 0, 0, 0) configurations_layout.setSpacing(0) configurations_layout.addWidget(scroll_widget, 1) - configurations_layout.addWidget(footer_widget, 0) + configurations_layout.addLayout(footer_layout, 0) - main_layout = QtWidgets.QHBoxLayout(self) + conf_wrapper_layout = QtWidgets.QHBoxLayout() + conf_wrapper_layout.setContentsMargins(0, 0, 0, 0) + conf_wrapper_layout.setSpacing(0) + conf_wrapper_layout.addLayout(configurations_layout, 1) + + main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) - main_layout.addWidget(configurations_widget, 1) + main_layout.addLayout(breadcrumbs_layout, 0) + main_layout.addLayout(conf_wrapper_layout, 1) save_btn.clicked.connect(self._save) refresh_btn.clicked.connect(self._on_refresh) + breadcrumbs_widget.path_edited.connect(self._on_path_edit) self.save_btn = save_btn self.refresh_btn = refresh_btn @@ -224,7 +242,9 @@ class SettingsCategoryWidget(QtWidgets.QWidget): self.scroll_widget = scroll_widget self.content_layout = content_layout self.content_widget = content_widget - self.configurations_widget = configurations_widget + self.breadcrumbs_widget = breadcrumbs_widget + self.breadcrumbs_model = None + self.conf_wrapper_layout = conf_wrapper_layout self.main_layout = main_layout self.ui_tweaks() @@ -232,6 +252,23 @@ class SettingsCategoryWidget(QtWidgets.QWidget): def ui_tweaks(self): return + def _on_path_edit(self, path): + for input_field in self.input_fields: + if input_field.make_sure_is_visible(path, True): + break + + def scroll_to(self, widget): + if widget: + # Process events which happened before ensurence + # - that is because some widgets could be not visible before + # this method was called and have incorrect size + QtWidgets.QApplication.processEvents() + # Scroll to widget + self.scroll_widget.ensureWidgetVisible(widget) + + def set_path(self, path): + self.breadcrumbs_widget.set_path(path) + def _add_developer_ui(self, footer_layout): modify_defaults_widget = QtWidgets.QWidget() modify_defaults_checkbox = QtWidgets.QCheckBox(modify_defaults_widget) @@ -427,10 +464,19 @@ class SettingsCategoryWidget(QtWidgets.QWidget): def _on_reset_crash(self): self.save_btn.setEnabled(False) + if self.breadcrumbs_model is not None: + self.breadcrumbs_model.set_entity(None) + def _on_reset_success(self): if not self.save_btn.isEnabled(): self.save_btn.setEnabled(True) + if self.breadcrumbs_model is not None: + path = self.breadcrumbs_widget.path() + self.breadcrumbs_widget.set_path("") + self.breadcrumbs_model.set_entity(self.entity) + self.breadcrumbs_widget.change_path(path) + def add_children_gui(self): for child_obj in self.entity.children: item = self.create_ui_for_entity(self, child_obj, self) @@ -521,6 +567,10 @@ class SystemWidget(SettingsCategoryWidget): self.modify_defaults_checkbox.setChecked(True) self.modify_defaults_checkbox.setEnabled(False) + def ui_tweaks(self): + self.breadcrumbs_model = SystemSettingsBreadcrumbs() + self.breadcrumbs_widget.set_model(self.breadcrumbs_model) + def _on_modify_defaults(self): if self.modify_defaults_checkbox.isChecked(): if not self.entity.is_in_defaults_state(): @@ -535,9 +585,12 @@ class ProjectWidget(SettingsCategoryWidget): self.project_name = None def ui_tweaks(self): + self.breadcrumbs_model = ProjectSettingsBreadcrumbs() + self.breadcrumbs_widget.set_model(self.breadcrumbs_model) + project_list_widget = ProjectListWidget(self) - self.main_layout.insertWidget(0, project_list_widget, 0) + self.conf_wrapper_layout.insertWidget(0, project_list_widget, 0) project_list_widget.project_changed.connect(self._on_project_change) diff --git a/openpype/tools/settings/settings/dict_conditional.py b/openpype/tools/settings/settings/dict_conditional.py index 31a4fa9fab..3e3270cac9 100644 --- a/openpype/tools/settings/settings/dict_conditional.py +++ b/openpype/tools/settings/settings/dict_conditional.py @@ -213,6 +213,26 @@ class DictConditionalWidget(BaseWidget): else: body_widget.hide_toolbox(hide_content=False) + def make_sure_is_visible(self, path, scroll_to): + if not path: + return False + + entity_path = self.entity.path + if entity_path == path: + self.set_focus(scroll_to) + return True + + if not path.startswith(entity_path): + return False + + if self.body_widget and not self.body_widget.is_expanded(): + self.body_widget.toggle_content(True) + + for input_field in self.input_fields: + if input_field.make_sure_is_visible(path, scroll_to): + return True + return False + def add_widget_to_layout(self, widget, label=None): if not widget.entity: map_id = widget.id diff --git a/openpype/tools/settings/settings/dict_mutable_widget.py b/openpype/tools/settings/settings/dict_mutable_widget.py index df6525e86a..ba86fe82dd 100644 --- a/openpype/tools/settings/settings/dict_mutable_widget.py +++ b/openpype/tools/settings/settings/dict_mutable_widget.py @@ -1,12 +1,11 @@ from uuid import uuid4 -from Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore, QtGui from .base import BaseWidget from .widgets import ( ExpandingWidget, - IconButton, - SpacerWidget + IconButton ) from openpype.tools.settings import ( BTN_FIXED_SIZE, @@ -15,6 +14,69 @@ from openpype.tools.settings import ( from openpype.settings.constants import KEY_REGEX +KEY_INPUT_TOOLTIP = ( + "Keys can't be duplicated and may contain alphabetical character (a-Z)" + "\nnumerical characters (0-9) dash (\"-\") or underscore (\"_\")." +) + + +class PaintHelper: + cached_icons = {} + + @classmethod + def _draw_image(cls, width, height, brush): + image = QtGui.QPixmap(width, height) + image.fill(QtCore.Qt.transparent) + + icon_path_stroker = QtGui.QPainterPathStroker() + icon_path_stroker.setCapStyle(QtCore.Qt.RoundCap) + icon_path_stroker.setJoinStyle(QtCore.Qt.RoundJoin) + icon_path_stroker.setWidth(height / 5) + + painter = QtGui.QPainter(image) + painter.setPen(QtCore.Qt.transparent) + painter.setBrush(brush) + rect = QtCore.QRect(0, 0, image.width(), image.height()) + fifteenth = rect.height() / 15 + # Left point + p1 = QtCore.QPoint( + rect.x() + (5 * fifteenth), + rect.y() + (9 * fifteenth) + ) + # Middle bottom point + p2 = QtCore.QPoint( + rect.center().x(), + rect.y() + (11 * fifteenth) + ) + # Top right point + p3 = QtCore.QPoint( + rect.x() + (10 * fifteenth), + rect.y() + (5 * fifteenth) + ) + + path = QtGui.QPainterPath(p1) + path.lineTo(p2) + path.lineTo(p3) + + stroked_path = icon_path_stroker.createStroke(path) + painter.drawPath(stroked_path) + + painter.end() + + return image + + @classmethod + def get_confirm_icon(cls, width, height): + key = "{}x{}-confirm_image".format(width, height) + icon = cls.cached_icons.get(key) + + if icon is None: + image = cls._draw_image(width, height, QtCore.Qt.white) + icon = QtGui.QIcon(image) + cls.cached_icons[key] = icon + return icon + + def create_add_btn(parent): add_btn = QtWidgets.QPushButton("+", parent) add_btn.setFocusPolicy(QtCore.Qt.ClickFocus) @@ -31,6 +93,19 @@ def create_remove_btn(parent): return remove_btn +def create_confirm_btn(parent): + confirm_btn = QtWidgets.QPushButton(parent) + + icon = PaintHelper.get_confirm_icon( + BTN_FIXED_SIZE, BTN_FIXED_SIZE + ) + confirm_btn.setIcon(icon) + confirm_btn.setFocusPolicy(QtCore.Qt.ClickFocus) + confirm_btn.setProperty("btn-type", "tool-item") + confirm_btn.setFixedSize(BTN_FIXED_SIZE, BTN_FIXED_SIZE) + return confirm_btn + + class ModifiableDictEmptyItem(QtWidgets.QWidget): def __init__(self, entity_widget, store_as_list, parent): super(ModifiableDictEmptyItem, self).__init__(parent) @@ -42,6 +117,8 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): self.is_duplicated = False self.key_is_valid = store_as_list + self.confirm_btn = None + if self.collapsible_key: self.create_collapsible_ui() else: @@ -61,7 +138,6 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): def create_addible_ui(self): add_btn = create_add_btn(self) remove_btn = create_remove_btn(self) - spacer_widget = SpacerWidget(self) remove_btn.setEnabled(False) @@ -70,13 +146,12 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): layout.setSpacing(3) layout.addWidget(add_btn, 0) layout.addWidget(remove_btn, 0) - layout.addWidget(spacer_widget, 1) + layout.addStretch(1) add_btn.clicked.connect(self._on_add_clicked) self.add_btn = add_btn self.remove_btn = remove_btn - self.spacer_widget = spacer_widget def _on_focus_lose(self): if self.key_input.hasFocus() or self.key_label_input.hasFocus(): @@ -111,7 +186,16 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): self.is_duplicated = self.entity_widget.is_key_duplicated(key) key_input_state = "" # Collapsible key and empty key are not invalid - if self.collapsible_key and self.key_input.text() == "": + key_value = self.key_input.text() + if self.confirm_btn is not None: + conf_disabled = ( + key_value == "" + or not self.key_is_valid + or self.is_duplicated + ) + self.confirm_btn.setEnabled(not conf_disabled) + + if self.collapsible_key and key_value == "": pass elif self.is_duplicated or not self.key_is_valid: key_input_state = "invalid" @@ -124,6 +208,7 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): def create_collapsible_ui(self): key_input = QtWidgets.QLineEdit(self) key_input.setObjectName("DictKey") + key_input.setToolTip(KEY_INPUT_TOOLTIP) key_label_input = QtWidgets.QLineEdit(self) @@ -141,11 +226,15 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): key_input_label_widget = QtWidgets.QLabel("Key:", self) key_label_input_label_widget = QtWidgets.QLabel("Label:", self) + confirm_btn = create_confirm_btn(self) + confirm_btn.setEnabled(False) + wrapper_widget = ExpandingWidget("", self) wrapper_widget.add_widget_after_label(key_input_label_widget) wrapper_widget.add_widget_after_label(key_input) wrapper_widget.add_widget_after_label(key_label_input_label_widget) wrapper_widget.add_widget_after_label(key_label_input) + wrapper_widget.add_widget_after_label(confirm_btn) wrapper_widget.hide_toolbox() layout = QtWidgets.QVBoxLayout(self) @@ -157,9 +246,12 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): key_input.returnPressed.connect(self._on_enter_press) key_label_input.returnPressed.connect(self._on_enter_press) + confirm_btn.clicked.connect(self._on_enter_press) + self.key_input = key_input self.key_label_input = key_label_input self.wrapper_widget = wrapper_widget + self.confirm_btn = confirm_btn class ModifiableDictItem(QtWidgets.QWidget): @@ -190,10 +282,14 @@ class ModifiableDictItem(QtWidgets.QWidget): self.key_label_input = None + self.confirm_btn = None + if collapsible_key: self.create_collapsible_ui() else: self.create_addible_ui() + + self.key_input.setToolTip(KEY_INPUT_TOOLTIP) self.update_style() @property @@ -277,6 +373,9 @@ class ModifiableDictItem(QtWidgets.QWidget): edit_btn.setProperty("btn-type", "tool-item-icon") edit_btn.setFixedHeight(BTN_FIXED_SIZE) + confirm_btn = create_confirm_btn(self) + confirm_btn.setVisible(False) + remove_btn = create_remove_btn(self) key_input_label_widget = QtWidgets.QLabel("Key:") @@ -286,6 +385,7 @@ class ModifiableDictItem(QtWidgets.QWidget): wrapper_widget.add_widget_after_label(key_input) wrapper_widget.add_widget_after_label(key_label_input_label_widget) wrapper_widget.add_widget_after_label(key_label_input) + wrapper_widget.add_widget_after_label(confirm_btn) wrapper_widget.add_widget_after_label(remove_btn) key_input.textChanged.connect(self._on_key_change) @@ -295,6 +395,7 @@ class ModifiableDictItem(QtWidgets.QWidget): key_label_input.returnPressed.connect(self._on_enter_press) edit_btn.clicked.connect(self.on_edit_pressed) + confirm_btn.clicked.connect(self._on_enter_press) remove_btn.clicked.connect(self.on_remove_clicked) # Hide edit inputs @@ -310,6 +411,7 @@ class ModifiableDictItem(QtWidgets.QWidget): self.key_label_input_label_widget = key_label_input_label_widget self.wrapper_widget = wrapper_widget self.edit_btn = edit_btn + self.confirm_btn = confirm_btn self.remove_btn = remove_btn self.content_widget = content_widget @@ -319,6 +421,9 @@ class ModifiableDictItem(QtWidgets.QWidget): self.category_widget, self.entity, self ) + def make_sure_is_visible(self, *args, **kwargs): + return self.input_field.make_sure_is_visible(*args, **kwargs) + def get_style_state(self): if self.is_invalid: return "invalid" @@ -415,6 +520,14 @@ class ModifiableDictItem(QtWidgets.QWidget): self.temp_key, key, self ) self.temp_key = key + if self.confirm_btn is not None: + conf_disabled = ( + key == "" + or not self.key_is_valid + or is_key_duplicated + ) + self.confirm_btn.setEnabled(not conf_disabled) + if is_key_duplicated or not self.key_is_valid: return @@ -434,7 +547,7 @@ class ModifiableDictItem(QtWidgets.QWidget): key_value = self.key_input.text() key_label_value = self.key_label_input.text() if key_label_value: - label = "{} ({})".format(key_label_value, key_value) + label = "{} ({})".format(key_value, key_label_value) else: label = key_value self.wrapper_widget.label_widget.setText(label) @@ -457,6 +570,7 @@ class ModifiableDictItem(QtWidgets.QWidget): self.key_input.setVisible(enabled) self.key_input_label_widget.setVisible(enabled) self.key_label_input.setVisible(enabled) + self.confirm_btn.setVisible(enabled) if not self.is_required: self.remove_btn.setVisible(enabled) if enabled: @@ -681,10 +795,6 @@ class DictMutableKeysWidget(BaseWidget): def remove_key(self, widget): key = self.entity.get_child_key(widget.entity) self.entity.pop(key) - # Poping of key from entity should remove the entity and input field. - # this is kept for testing purposes. - if widget in self.input_fields: - self.remove_row(widget) def change_key(self, new_key, widget): if not new_key or widget.is_key_duplicated: @@ -751,6 +861,11 @@ class DictMutableKeysWidget(BaseWidget): return input_field def remove_row(self, widget): + if widget.is_key_duplicated: + new_key = widget.uuid_key + if new_key is None: + new_key = str(uuid4()) + self.validate_key_duplication(widget.temp_key, new_key, widget) self.input_fields.remove(widget) self.content_layout.removeWidget(widget) widget.deleteLater() @@ -834,7 +949,10 @@ class DictMutableKeysWidget(BaseWidget): _input_field.set_entity_value() else: - if input_field.key_value() != key: + if ( + not input_field.is_key_duplicated + and input_field.key_value() != key + ): changed = True input_field.set_key(key) @@ -846,6 +964,26 @@ class DictMutableKeysWidget(BaseWidget): if changed: self.on_shuffle() + def make_sure_is_visible(self, path, scroll_to): + if not path: + return False + + entity_path = self.entity.path + if entity_path == path: + self.set_focus(scroll_to) + return True + + if not path.startswith(entity_path): + return False + + if self.body_widget and not self.body_widget.is_expanded(): + self.body_widget.toggle_content(True) + + for input_field in self.input_fields: + if input_field.make_sure_is_visible(path, scroll_to): + return True + return False + def set_entity_value(self): while self.input_fields: self.remove_row(self.input_fields[0]) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 82afbb0a13..d29fa6f42b 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -6,8 +6,10 @@ from .widgets import ( ExpandingWidget, NumberSpinBox, GridLabelWidget, - ComboBox, - NiceCheckbox + SettingsComboBox, + NiceCheckbox, + SettingsPlainTextEdit, + SettingsLineEdit ) from .multiselection_combobox import MultiSelectionComboBox from .wrapper_widgets import ( @@ -46,6 +48,7 @@ class DictImmutableKeysWidget(BaseWidget): self._ui_item_base() label = self.entity.label + self._direct_children_widgets = [] self._parent_widget_by_entity_id = {} self._added_wrapper_ids = set() self._prepare_entity_layouts( @@ -154,9 +157,41 @@ class DictImmutableKeysWidget(BaseWidget): else: body_widget.hide_toolbox(hide_content=False) + def make_sure_is_visible(self, path, scroll_to): + if not path: + return False + + entity_path = self.entity.path + if entity_path == path: + self.set_focus(scroll_to) + return True + + if not path.startswith(entity_path): + return False + + is_checkbox_child = False + changed = False + for direct_child in self._direct_children_widgets: + if direct_child.make_sure_is_visible(path, scroll_to): + changed = True + if direct_child.entity is self.checkbox_child: + is_checkbox_child = True + break + + # Change scroll to this widget + if is_checkbox_child: + self.scroll_to(self) + + elif self.body_widget and not self.body_widget.is_expanded(): + # Expand widget if is callapsible + self.body_widget.toggle_content(True) + + return changed + def add_widget_to_layout(self, widget, label=None): if self.checkbox_child and widget.entity is self.checkbox_child: self.body_widget.add_widget_before_label(widget) + self._direct_children_widgets.append(widget) return if not widget.entity: @@ -172,6 +207,8 @@ class DictImmutableKeysWidget(BaseWidget): self._added_wrapper_ids.add(wrapper.id) return + self._direct_children_widgets.append(widget) + row = self.content_layout.rowCount() if not label or isinstance(widget, WrapperWidget): self.content_layout.addWidget(widget, row, 0, 1, 2) @@ -270,11 +307,8 @@ class BoolWidget(InputWidget): height=checkbox_height, parent=self.content_widget ) - spacer = QtWidgets.QWidget(self.content_widget) - spacer.setAttribute(QtCore.Qt.WA_TranslucentBackground) - self.content_layout.addWidget(self.input_field, 0) - self.content_layout.addWidget(spacer, 1) + self.content_layout.addStretch(1) self.setFocusProxy(self.input_field) @@ -297,9 +331,9 @@ class TextWidget(InputWidget): def _add_inputs_to_layout(self): multiline = self.entity.multiline if multiline: - self.input_field = QtWidgets.QPlainTextEdit(self.content_widget) + self.input_field = SettingsPlainTextEdit(self.content_widget) else: - self.input_field = QtWidgets.QLineEdit(self.content_widget) + self.input_field = SettingsLineEdit(self.content_widget) placeholder_text = self.entity.placeholder_text if placeholder_text: @@ -313,8 +347,12 @@ class TextWidget(InputWidget): self.content_layout.addWidget(self.input_field, 1, **layout_kwargs) + self.input_field.focused_in.connect(self._on_input_focus) self.input_field.textChanged.connect(self._on_value_change) + def _on_input_focus(self): + self.focused_in() + def _on_entity_change(self): if self.entity.value != self.input_value(): self.set_entity_value() @@ -352,6 +390,10 @@ class NumberWidget(InputWidget): self.content_layout.addWidget(self.input_field, 1) self.input_field.valueChanged.connect(self._on_value_change) + self.input_field.focused_in.connect(self._on_input_focus) + + def _on_input_focus(self): + self.focused_in() def _on_entity_change(self): if self.entity.value != self.input_field.value(): @@ -366,7 +408,7 @@ class NumberWidget(InputWidget): self.entity.set(self.input_field.value()) -class RawJsonInput(QtWidgets.QPlainTextEdit): +class RawJsonInput(SettingsPlainTextEdit): tab_length = 4 def __init__(self, valid_type, *args, **kwargs): @@ -428,15 +470,18 @@ class RawJsonWidget(InputWidget): QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.MinimumExpanding ) - self.setFocusProxy(self.input_field) self.content_layout.addWidget( self.input_field, 1, alignment=QtCore.Qt.AlignTop ) + self.input_field.focused_in.connect(self._on_input_focus) self.input_field.textChanged.connect(self._on_value_change) + def _on_input_focus(self): + self.focused_in() + def set_entity_value(self): self.input_field.set_value(self.entity.value) self._is_invalid = self.input_field.has_invalid_value() @@ -470,7 +515,7 @@ class EnumeratorWidget(InputWidget): ) else: - self.input_field = ComboBox(self.content_widget) + self.input_field = SettingsComboBox(self.content_widget) for enum_item in self.entity.enum_items: for value, label in enum_item.items(): @@ -480,8 +525,12 @@ class EnumeratorWidget(InputWidget): self.setFocusProxy(self.input_field) + self.input_field.focused_in.connect(self._on_input_focus) self.input_field.value_changed.connect(self._on_value_change) + def _on_input_focus(self): + self.focused_in() + def _on_entity_change(self): if self.entity.value != self.input_field.value(): self.set_entity_value() @@ -562,6 +611,9 @@ class PathWidget(BaseWidget): def set_entity_value(self): self.input_field.set_entity_value() + def make_sure_is_visible(self, *args, **kwargs): + return self.input_field.make_sure_is_visible(*args, **kwargs) + def hierarchical_style_update(self): self.update_style() self.input_field.hierarchical_style_update() @@ -632,14 +684,19 @@ class PathWidget(BaseWidget): class PathInputWidget(InputWidget): def _add_inputs_to_layout(self): - self.input_field = QtWidgets.QLineEdit(self.content_widget) + self.input_field = SettingsLineEdit(self.content_widget) placeholder = self.entity.placeholder_text if placeholder: self.input_field.setPlaceholderText(placeholder) self.setFocusProxy(self.input_field) self.content_layout.addWidget(self.input_field) + self.input_field.textChanged.connect(self._on_value_change) + self.input_field.focused_in.connect(self._on_input_focus) + + def _on_input_focus(self): + self.focused_in() def _on_entity_change(self): if self.entity.value != self.input_value(): diff --git a/openpype/tools/settings/settings/list_item_widget.py b/openpype/tools/settings/settings/list_item_widget.py index 82ca541132..17412a30b9 100644 --- a/openpype/tools/settings/settings/list_item_widget.py +++ b/openpype/tools/settings/settings/list_item_widget.py @@ -18,8 +18,6 @@ class EmptyListItem(QtWidgets.QWidget): add_btn = QtWidgets.QPushButton("+", self) remove_btn = QtWidgets.QPushButton("-", self) - spacer_widget = QtWidgets.QWidget(self) - spacer_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) add_btn.setFocusPolicy(QtCore.Qt.ClickFocus) remove_btn.setEnabled(False) @@ -35,13 +33,12 @@ class EmptyListItem(QtWidgets.QWidget): layout.setSpacing(3) layout.addWidget(add_btn, 0) layout.addWidget(remove_btn, 0) - layout.addWidget(spacer_widget, 1) + layout.addStretch(1) add_btn.clicked.connect(self._on_add_clicked) self.add_btn = add_btn self.remove_btn = remove_btn - self.spacer_widget = spacer_widget def _on_add_clicked(self): self.entity_widget.add_new_item() @@ -101,12 +98,6 @@ class ListItem(QtWidgets.QWidget): self.category_widget, self.entity, self ) - spacer_widget = QtWidgets.QWidget(self) - spacer_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) - spacer_widget.setVisible(False) - - layout.addWidget(spacer_widget, 1) - layout.addWidget(up_btn, 0) layout.addWidget(down_btn, 0) @@ -115,7 +106,8 @@ class ListItem(QtWidgets.QWidget): self.up_btn = up_btn self.down_btn = down_btn - self.spacer_widget = spacer_widget + self._row = -1 + self._is_last = False @property def category_widget(self): @@ -126,6 +118,9 @@ class ListItem(QtWidgets.QWidget): *args, **kwargs ) + def make_sure_is_visible(self, *args, **kwargs): + return self.input_field.make_sure_is_visible(*args, **kwargs) + @property def is_invalid(self): return self.input_field.is_invalid @@ -136,28 +131,40 @@ class ListItem(QtWidgets.QWidget): def add_widget_to_layout(self, widget, label=None): self.content_layout.addWidget(widget, 1) + def set_row(self, row, is_last): + if row == self._row and is_last == self._is_last: + return + + trigger_order_changed = ( + row != self._row + or is_last != self._is_last + ) + self._row = row + self._is_last = is_last + + if trigger_order_changed: + self.order_changed() + + @property def row(self): - return self.entity_widget.input_fields.index(self) + return self._row def parent_rows_count(self): return len(self.entity_widget.input_fields) def _on_add_clicked(self): - self.entity_widget.add_new_item(row=self.row() + 1) + self.entity_widget.add_new_item(row=self.row + 1) def _on_remove_clicked(self): self.entity_widget.remove_row(self) def _on_up_clicked(self): - row = self.row() - self.entity_widget.swap_rows(row - 1, row) + self.entity_widget.swap_rows(self.row - 1, self.row) def _on_down_clicked(self): - row = self.row() - self.entity_widget.swap_rows(row, row + 1) + self.entity_widget.swap_rows(self.row, self.row + 1) def order_changed(self): - row = self.row() parent_row_count = self.parent_rows_count() if parent_row_count == 1: self.up_btn.setVisible(False) @@ -168,11 +175,11 @@ class ListItem(QtWidgets.QWidget): self.up_btn.setVisible(True) self.down_btn.setVisible(True) - if row == 0: + if self.row == 0: self.up_btn.setEnabled(False) self.down_btn.setEnabled(True) - elif row == parent_row_count - 1: + elif self.row == parent_row_count - 1: self.up_btn.setEnabled(True) self.down_btn.setEnabled(False) @@ -191,6 +198,7 @@ class ListWidget(InputWidget): def create_ui(self): self._child_style_state = "" self.input_fields = [] + self._input_fields_by_entity_id = {} main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) @@ -243,8 +251,7 @@ class ListWidget(InputWidget): self.entity_widget.add_widget_to_layout(self, entity_label) def set_entity_value(self): - for input_field in tuple(self.input_fields): - self.remove_row(input_field) + self.remove_all_rows() for entity in self.entity.children: self.add_row(entity) @@ -260,41 +267,82 @@ class ListWidget(InputWidget): invalid.extend(input_field.get_invalid()) return invalid + def make_sure_is_visible(self, path, scroll_to): + if not path: + return False + + entity_path = self.entity.path + if entity_path == path: + self.set_focus(scroll_to) + return True + + if not path.startswith(entity_path): + return False + + if self.body_widget and not self.body_widget.is_expanded(): + self.body_widget.toggle_content(True) + + for input_field in self.input_fields: + if input_field.make_sure_is_visible(path, scroll_to): + return True + return False + def _on_entity_change(self): # TODO do less inefficient - input_field_last_idx = len(self.input_fields) - 1 - child_len = len(self.entity) + childen_order = [] + new_children = [] for idx, child_entity in enumerate(self.entity): - if idx > input_field_last_idx: - self.add_row(child_entity, idx) - input_field_last_idx += 1 + input_field = self._input_fields_by_entity_id.get(child_entity.id) + if input_field is not None: + childen_order.append(input_field) + else: + new_children.append((idx, child_entity)) + + order_changed = False + for idx, input_field in enumerate(childen_order): + current_field = self.input_fields[idx] + if current_field is input_field: continue + order_changed = True + old_idx = self.input_fields.index(input_field) + self.input_fields[old_idx], self.input_fields[idx] = ( + current_field, input_field + ) + self.content_layout.insertWidget(idx + 1, input_field) - if self.input_fields[idx].entity is child_entity: - continue + kept_len = len(childen_order) + fields_len = len(self.input_fields) + if fields_len > kept_len: + order_changed = True + for row in reversed(range(kept_len, fields_len)): + self.remove_row(row=row) - input_field_idx = None - for _input_field_idx, input_field in enumerate(self.input_fields): - if input_field.entity is child_entity: - input_field_idx = _input_field_idx - break + for idx, child_entity in new_children: + order_changed = False + self.add_row(child_entity, idx) - if input_field_idx is None: - self.add_row(child_entity, idx) - input_field_last_idx += 1 - continue + if not order_changed: + return - input_field = self.input_fields.pop(input_field_idx) - self.input_fields.insert(idx, input_field) - self.content_layout.insertWidget(idx, input_field) + self._on_order_change() - new_input_field_len = len(self.input_fields) - if child_len != new_input_field_len: - for _idx in range(child_len, new_input_field_len): - # Remove row at the same index - self.remove_row(self.input_fields[child_len]) + input_field_len = self.count() + self.empty_row.setVisible(input_field_len == 0) - self.empty_row.setVisible(self.count() == 0) + def _on_order_change(self): + last_idx = self.count() - 1 + previous_input = None + for idx, input_field in enumerate(self.input_fields): + input_field.set_row(idx, idx == last_idx) + next_input = input_field.input_field.focusProxy() + if previous_input is not None: + self.setTabOrder(previous_input, next_input) + else: + self.setTabOrder(self, next_input) + previous_input = next_input + + if previous_input is not None: + self.setTabOrder(previous_input, self) def count(self): return len(self.input_fields) @@ -307,32 +355,20 @@ class ListWidget(InputWidget): def add_new_item(self, row=None): new_entity = self.entity.add_new_item(row) - for input_field in self.input_fields: - if input_field.entity is new_entity: - input_field.input_field.setFocus(True) - break + input_field = self._input_fields_by_entity_id.get(new_entity.id) + if input_field is not None: + input_field.input_field.setFocus(True) return new_entity def add_row(self, child_entity, row=None): # Create new item item_widget = ListItem(child_entity, self) - - previous_field = None - next_field = None + self._input_fields_by_entity_id[child_entity.id] = item_widget if row is None: - if self.input_fields: - previous_field = self.input_fields[-1] self.content_layout.addWidget(item_widget) self.input_fields.append(item_widget) else: - if row > 0: - previous_field = self.input_fields[row - 1] - - max_index = self.count() - if row < max_index: - next_field = self.input_fields[row] - self.content_layout.insertWidget(row + 1, item_widget) self.input_fields.insert(row, item_widget) @@ -342,49 +378,53 @@ class ListWidget(InputWidget): # added as widget here which won't because is not in input_fields item_widget.input_field.set_entity_value() - if previous_field: - previous_field.order_changed() + self._on_order_change() - if next_field: - next_field.order_changed() - - item_widget.order_changed() - - previous_input = None - for input_field in self.input_fields: - if previous_input is not None: - self.setTabOrder( - previous_input, input_field.input_field.focusProxy() - ) - previous_input = input_field.input_field.focusProxy() + input_field_len = self.count() + self.empty_row.setVisible(input_field_len == 0) self.updateGeometry() - def remove_row(self, item_widget): - row = self.input_fields.index(item_widget) - previous_field = None - next_field = None - if row > 0: - previous_field = self.input_fields[row - 1] + def remove_all_rows(self): + self._input_fields_by_entity_id = {} + while self.input_fields: + item_widget = self.input_fields.pop(0) + self.content_layout.removeWidget(item_widget) + item_widget.setParent(None) + item_widget.deleteLater() - if row != len(self.input_fields) - 1: - next_field = self.input_fields[row + 1] + self.empty_row.setVisible(True) + + self.updateGeometry() + + def remove_row(self, item_widget=None, row=None): + if item_widget is None: + item_widget = self.input_fields[row] + elif row is None: + row = self.input_fields.index(item_widget) self.content_layout.removeWidget(item_widget) self.input_fields.pop(row) + self._input_fields_by_entity_id.pop(item_widget.entity.id) item_widget.setParent(None) item_widget.deleteLater() if item_widget.entity in self.entity: self.entity.remove(item_widget.entity) - if previous_field: - previous_field.order_changed() + rows = self.count() + any_item = rows == 0 + if any_item: + start_row = 0 + if row > 0: + start_row = row - 1 - if next_field: - next_field.order_changed() + last_row = rows - 1 + _enum = enumerate(self.input_fields[start_row:rows]) + for idx, _item_widget in _enum: + _item_widget.set_row(idx, idx == last_row) - self.empty_row.setVisible(self.count() == 0) + self.empty_row.setVisible(any_item) self.updateGeometry() diff --git a/openpype/tools/settings/settings/list_strict_widget.py b/openpype/tools/settings/settings/list_strict_widget.py index 340db2e8c6..046b6992f6 100644 --- a/openpype/tools/settings/settings/list_strict_widget.py +++ b/openpype/tools/settings/settings/list_strict_widget.py @@ -65,6 +65,21 @@ class ListStrictWidget(BaseWidget): invalid.extend(input_field.get_invalid()) return invalid + def make_sure_is_visible(self, path, scroll_to): + if not path: + return False + + entity_path = self.entity.path + if entity_path == path: + self.set_focus(scroll_to) + return True + + if path.startswith(entity_path): + for input_field in self.input_fields: + if input_field.make_sure_is_visible(path, scroll_to): + return True + return False + def add_widget_to_layout(self, widget, label=None): # Horizontally added children if self.entity.is_horizontal: diff --git a/openpype/tools/settings/settings/multiselection_combobox.py b/openpype/tools/settings/settings/multiselection_combobox.py index 30ecb7b84b..176f4cab8c 100644 --- a/openpype/tools/settings/settings/multiselection_combobox.py +++ b/openpype/tools/settings/settings/multiselection_combobox.py @@ -21,6 +21,8 @@ class ComboItemDelegate(QtWidgets.QStyledItemDelegate): class MultiSelectionComboBox(QtWidgets.QComboBox): value_changed = QtCore.Signal() + focused_in = QtCore.Signal() + ignored_keys = { QtCore.Qt.Key_Up, QtCore.Qt.Key_Down, @@ -56,6 +58,10 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): self.lines = {} self.item_height = None + def focusInEvent(self, event): + self.focused_in.emit() + return super(MultiSelectionComboBox, self).focusInEvent(event) + def mousePressEvent(self, event): """Reimplemented.""" self._popup_is_shown = False diff --git a/openpype/tools/settings/settings/style/style.css b/openpype/tools/settings/settings/style/style.css index 3ce9837a8b..250c15063f 100644 --- a/openpype/tools/settings/settings/style/style.css +++ b/openpype/tools/settings/settings/style/style.css @@ -388,4 +388,32 @@ QTableView::item:pressed, QListView::item:pressed, QTreeView::item:pressed { QTableView::item:selected:active, QTreeView::item:selected:active, QListView::item:selected:active { background: #3d8ec9; -} \ No newline at end of file +} + +#BreadcrumbsPathInput { + padding: 2px; + font-size: 9pt; +} + +#BreadcrumbsButton { + padding-right: 12px; + font-size: 9pt; +} + +#BreadcrumbsButton[empty="1"] { + padding-right: 0px; +} + +#BreadcrumbsButton::menu-button { + width: 12px; + background: rgba(127, 127, 127, 60); +} +#BreadcrumbsButton::menu-button:hover { + background: rgba(127, 127, 127, 90); +} + +#BreadcrumbsPanel { + border: 1px solid #4e5254; + border-radius: 5px; + background: #21252B;; +} diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index b20ce5ed66..34b222dd8e 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -9,6 +9,22 @@ from avalon.mongodb import ( from openpype.settings.lib import get_system_settings +class SettingsLineEdit(QtWidgets.QLineEdit): + focused_in = QtCore.Signal() + + def focusInEvent(self, event): + super(SettingsLineEdit, self).focusInEvent(event) + self.focused_in.emit() + + +class SettingsPlainTextEdit(QtWidgets.QPlainTextEdit): + focused_in = QtCore.Signal() + + def focusInEvent(self, event): + super(SettingsPlainTextEdit, self).focusInEvent(event) + self.focused_in.emit() + + class ShadowWidget(QtWidgets.QWidget): def __init__(self, message, parent): super(ShadowWidget, self).__init__(parent) @@ -70,6 +86,8 @@ class IconButton(QtWidgets.QPushButton): class NumberSpinBox(QtWidgets.QDoubleSpinBox): + focused_in = QtCore.Signal() + def __init__(self, *args, **kwargs): min_value = kwargs.pop("minimum", -99999) max_value = kwargs.pop("maximum", 99999) @@ -80,6 +98,10 @@ class NumberSpinBox(QtWidgets.QDoubleSpinBox): self.setMinimum(min_value) self.setMaximum(max_value) + def focusInEvent(self, event): + super(NumberSpinBox, self).focusInEvent(event) + self.focused_in.emit() + def wheelEvent(self, event): if self.hasFocus(): super(NumberSpinBox, self).wheelEvent(event) @@ -93,18 +115,23 @@ class NumberSpinBox(QtWidgets.QDoubleSpinBox): return output -class ComboBox(QtWidgets.QComboBox): +class SettingsComboBox(QtWidgets.QComboBox): value_changed = QtCore.Signal() + focused_in = QtCore.Signal() def __init__(self, *args, **kwargs): - super(ComboBox, self).__init__(*args, **kwargs) + super(SettingsComboBox, self).__init__(*args, **kwargs) self.currentIndexChanged.connect(self._on_change) self.setFocusPolicy(QtCore.Qt.StrongFocus) def wheelEvent(self, event): if self.hasFocus(): - return super(ComboBox, self).wheelEvent(event) + return super(SettingsComboBox, self).wheelEvent(event) + + def focusInEvent(self, event): + self.focused_in.emit() + return super(SettingsComboBox, self).focusInEvent(event) def _on_change(self, *args, **kwargs): self.value_changed.emit() @@ -160,15 +187,13 @@ class ExpandingWidget(QtWidgets.QWidget): after_label_layout = QtWidgets.QHBoxLayout(after_label_widget) after_label_layout.setContentsMargins(0, 0, 0, 0) - spacer_widget = QtWidgets.QWidget(side_line_widget) - side_line_layout = QtWidgets.QHBoxLayout(side_line_widget) side_line_layout.setContentsMargins(5, 10, 0, 10) side_line_layout.addWidget(button_toggle) side_line_layout.addWidget(before_label_widget) side_line_layout.addWidget(label_widget) side_line_layout.addWidget(after_label_widget) - side_line_layout.addWidget(spacer_widget, 1) + side_line_layout.addStretch(1) top_part_layout = QtWidgets.QHBoxLayout(top_part) top_part_layout.setContentsMargins(0, 0, 0, 0) @@ -176,7 +201,6 @@ class ExpandingWidget(QtWidgets.QWidget): before_label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) after_label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) - spacer_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) self.setAttribute(QtCore.Qt.WA_TranslucentBackground) @@ -215,6 +239,9 @@ class ExpandingWidget(QtWidgets.QWidget): self.main_layout.addWidget(content_widget) self.content_widget = content_widget + def is_expanded(self): + return self.button_toggle.isChecked() + def _btn_clicked(self): self.toggle_content(self.button_toggle.isChecked()) @@ -341,31 +368,21 @@ class GridLabelWidget(QtWidgets.QWidget): self.properties = {} + label_widget = QtWidgets.QLabel(label, self) + + label_proxy_layout = QtWidgets.QHBoxLayout() + label_proxy_layout.setContentsMargins(0, 0, 0, 0) + label_proxy_layout.setSpacing(0) + + label_proxy_layout.addWidget(label_widget, 0, QtCore.Qt.AlignRight) + layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 2, 0, 0) layout.setSpacing(0) - label_proxy = QtWidgets.QWidget(self) + layout.addLayout(label_proxy_layout, 0) + layout.addStretch(1) - label_proxy_layout = QtWidgets.QHBoxLayout(label_proxy) - label_proxy_layout.setContentsMargins(0, 0, 0, 0) - label_proxy_layout.setSpacing(0) - - label_widget = QtWidgets.QLabel(label, label_proxy) - spacer_widget_h = SpacerWidget(label_proxy) - label_proxy_layout.addWidget( - spacer_widget_h, 0, alignment=QtCore.Qt.AlignRight - ) - label_proxy_layout.addWidget( - label_widget, 0, alignment=QtCore.Qt.AlignRight - ) - - spacer_widget_v = SpacerWidget(self) - - layout.addWidget(label_proxy, 0) - layout.addWidget(spacer_widget_v, 1) - - label_proxy.setAttribute(QtCore.Qt.WA_TranslucentBackground) label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) self.label_widget = label_widget @@ -380,6 +397,8 @@ class GridLabelWidget(QtWidgets.QWidget): def mouseReleaseEvent(self, event): if self.input_field: + if event and event.button() == QtCore.Qt.LeftButton: + self.input_field.focused_in() return self.input_field.show_actions_menu(event) return super(GridLabelWidget, self).mouseReleaseEvent(event) diff --git a/openpype/tools/settings/settings/wrapper_widgets.py b/openpype/tools/settings/settings/wrapper_widgets.py index 915a2cf875..b14a226912 100644 --- a/openpype/tools/settings/settings/wrapper_widgets.py +++ b/openpype/tools/settings/settings/wrapper_widgets.py @@ -19,6 +19,14 @@ class WrapperWidget(QtWidgets.QWidget): self.create_ui() + def make_sure_is_visible(self, *args, **kwargs): + changed = False + for input_field in self.input_fields: + if input_field.make_sure_is_visible(*args, **kwargs): + changed = True + break + return changed + def create_ui(self): raise NotImplementedError( "{} does not have implemented `create_ui`.".format( @@ -89,6 +97,14 @@ class CollapsibleWrapper(WrapperWidget): else: body_widget.hide_toolbox(hide_content=False) + def make_sure_is_visible(self, *args, **kwargs): + result = super(CollapsibleWrapper, self).make_sure_is_visible( + *args, **kwargs + ) + if result: + self.body_widget.toggle_content(True) + return result + def add_widget_to_layout(self, widget, label=None): self.input_fields.append(widget) diff --git a/openpype/version.py b/openpype/version.py index 473be3bafc..e804077e54 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.3.0-nightly.7" +__version__ = "3.3.1-nightly.1" diff --git a/poetry.lock b/poetry.lock index aad1898983..e011b781c9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,7 +11,7 @@ develop = false type = "git" url = "https://github.com/pypeclub/acre.git" reference = "master" -resolved_reference = "5a812c6dcfd3aada87adb49be98c548c894d6566" +resolved_reference = "55a7c331e6dc5f81639af50ca4a8cc9d73e9273d" [[package]] name = "aiohttp" diff --git a/start.py b/start.py index 419a956835..6473a926d0 100644 --- a/start.py +++ b/start.py @@ -221,10 +221,14 @@ def set_openpype_global_environments() -> None: all_env = get_environments() general_env = all_env["global"] - env = acre.merge( + merged_env = acre.merge( acre.parse(general_env), dict(os.environ) ) + env = acre.compute( + merged_env, + cleanup=False + ) os.environ.clear() os.environ.update(env) diff --git a/tools/create_env.ps1 b/tools/create_env.ps1 index e2ec401bb3..f19a98f11b 100644 --- a/tools/create_env.ps1 +++ b/tools/create_env.ps1 @@ -50,8 +50,18 @@ function Install-Poetry() { Write-Host "Installing Poetry ... " $python = "python" if (Get-Command "pyenv" -ErrorAction SilentlyContinue) { + if (-not (Test-Path -PathType Leaf -Path "$($openpype_root)\.python-version")) { + $result = & pyenv global + if ($result -eq "no global version configured") { + Write-Host "!!! " -NoNewline -ForegroundColor Red + Write-Host "Using pyenv but having no local or global version of Python set." + Exit-WithCode 1 + } + } $python = & pyenv which python + } + $env:POETRY_HOME="$openpype_root\.poetry" (Invoke-WebRequest -Uri https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py -UseBasicParsing).Content | & $($python) - } diff --git a/website/docs/admin_hosts_maya.md b/website/docs/admin_hosts_maya.md index 5e0aa15345..05a231c21a 100644 --- a/website/docs/admin_hosts_maya.md +++ b/website/docs/admin_hosts_maya.md @@ -87,6 +87,26 @@ When you publish your model with top group named like `foo_GRP` it will fail. Bu All regexes used here are in Python variant. ::: +### Maya > Deadline submitter +This plugin provides connection between Maya and Deadline. It is using [Deadline Webservice](https://docs.thinkboxsoftware.com/products/deadline/10.0/1_User%20Manual/manual/web-service.html) to submit jobs to farm. +![Maya > Deadline Settings](assets/maya-admin_submit_maya_job_to_deadline.png) + +You can set various aspects of scene submission to farm with per-project settings in **Setting UI**. + + - **Optional** will mark sumission plugin optional + - **Active** will enable/disable plugin + - **Tile Assembler Plugin** will set what should be used to assemble tiles on Deadline. Either **Open Image IO** will be used +or Deadlines **Draft Tile Assembler**. + - **Use Published scene** enable to render from published scene instead of scene in work area. Rendering from published files is much safer. + - **Use Asset dependencies** will mark job pending on farm until asset dependencies are fulfilled - for example Deadline will wait for scene file to be synced to cloud, etc. + - **Group name** use specific Deadline group for the job. + - **Limit Groups** use these Deadline Limit groups for the job. + - **Additional `JobInfo` data** JSON of additional Deadline options that will be embedded in `JobInfo` part of the submission data. + - **Additional `PluginInfo` data** JSON of additional Deadline options that will be embedded in `PluginInfo` part of the submission data. + - **Scene patches** - configure mechanism to add additional lines to published Maya Ascii scene files before they are used for rendering. +This is useful to fix some specific renderer glitches and advanced hacking of Maya Scene files. `Patch name` is label for patch for easier orientation. +`Patch regex` is regex used to find line in file, after `Patch line` string is inserted. Note that you need to add line ending. + ## Custom Menu You can add your custom tools menu into Maya by extending definitions in **Maya -> Scripts Menu Definition**. ![Custom menu definition](assets/maya-admin_scriptsmenu.png) @@ -94,4 +114,9 @@ You can add your custom tools menu into Maya by extending definitions in **Maya :::note Work in progress This is still work in progress. Menu definition will be handled more friendly with widgets and not raw json. -::: \ No newline at end of file +::: + +## Multiplatform path mapping +You can configure path mapping using Maya `dirmap` command. This will add bi-directional mapping between +list of paths specified in **Settings**. You can find it in **Settings -> Project Settings -> Maya -> Maya Directory Mapping** +![Dirmap settings](assets/maya-admin_dirmap_settings.png) diff --git a/website/docs/artist_hosts_aftereffects.md b/website/docs/artist_hosts_aftereffects.md index 879c0d4646..fffc6302b7 100644 --- a/website/docs/artist_hosts_aftereffects.md +++ b/website/docs/artist_hosts_aftereffects.md @@ -22,7 +22,7 @@ Drag extension.zxp and drop it to Anastasyi's Extension Manager. The extension w ## Implemented functionality AfterEffects implementation currently allows you to import and add various media to composition (image plates, renders, audio files, video files etc.) -and send prepared composition for rendering to Deadline. +and send prepared composition for rendering to Deadline or render locally. ## Usage @@ -53,6 +53,12 @@ will be changed. ### Publish +#### RenderQueue + +AE's Render Queue is required for publishing locally or on a farm. Artist needs to configure expected result format (extension, resolution) in the Render Queue in an Output module. Currently its expected to have only single render item and single output module in the Render Queue. + +AE might throw some warning windows during publishing locally, so please pay attention to them in a case publishing seems to be stuck in a `Extract Local Render`. + When you are ready to share your work, you will need to publish it. This is done by opening the `Publish` by clicking the corresponding button in the OpenPype Panel. ![Publish](assets/aftereffects_publish.png) diff --git a/website/docs/artist_hosts_maya.md b/website/docs/artist_hosts_maya.md index 6fbd59ae1e..6387da4adc 100644 --- a/website/docs/artist_hosts_maya.md +++ b/website/docs/artist_hosts_maya.md @@ -701,6 +701,32 @@ under `input_SET`). This mechanism uses *cbId* attribute on those shapes. If match is found shapes are connected using their `outMesh` and `outMesh`. Thus you can easily connect existing animation to loaded rig. ::: +## Working with Xgen in OpenPype + +OpenPype support publishing and loading of Xgen interactive grooms. You can publish +them as mayaAscii files with scalps that can be loaded into another maya scene, or as +alembic caches. + +### Publishing Xgen Grooms + +To prepare xgen for publishing just select all the descriptions that should be published together and the create Xgen Subset in the scene using - **OpenPype menu** β†’ **Create**... and select **Xgen Interactive**. Leave Use selection checked. + +For actual publishing of your groom to go **OpenPype β†’ Publish** and then press β–Ά to publish. This will export `.ma` file containing your grooms with any geometries they are attached to and also a baked cache in `.abc` format + + +:::tip adding more descriptions +You can add multiple xgen desctiption into the subset you are about to publish, simply by +adding them to the maya set that was created for you. Please make sure that only xgen description nodes are present inside of the set and not the scalp geometry. +::: + +### Loading Xgen + +You can use published xgens by loading them using OpenPype Publisher. You can choose to reference or import xgen. We don't have any automatic mesh linking at the moment and it is expected, that groom is published with a scalp, that can then be manually attached to your animated mesh for example. + +The alembic representation can be loaded too and it contains the groom converted to curves. Keep in mind that the density of the alembic directly depends on your viewport xgen density at the point of export. + + + ## Using Redshift Proxies OpenPype supports working with Redshift Proxy files. You can create Redshift Proxy from almost diff --git a/website/docs/assets/maya-admin_dirmap_settings.png b/website/docs/assets/maya-admin_dirmap_settings.png new file mode 100644 index 0000000000..9d5780dfc8 Binary files /dev/null and b/website/docs/assets/maya-admin_dirmap_settings.png differ diff --git a/website/docs/assets/maya-admin_submit_maya_job_to_deadline.png b/website/docs/assets/maya-admin_submit_maya_job_to_deadline.png new file mode 100644 index 0000000000..56b720dc5d Binary files /dev/null and b/website/docs/assets/maya-admin_submit_maya_job_to_deadline.png differ diff --git a/website/static/img/logos/pypeclub_black.svg b/website/static/img/logos/pypeclub_black.svg index b749edbdb3..6c209977fe 100644 --- a/website/static/img/logos/pypeclub_black.svg +++ b/website/static/img/logos/pypeclub_black.svg @@ -1,8 +1,8 @@ - - + + @@ -20,7 +20,21 @@ - .club + + + + + + + + + + + + + + + diff --git a/website/static/img/logos/pypeclub_color_white.svg b/website/static/img/logos/pypeclub_color_white.svg index c82946d82b..ffa194aa47 100644 --- a/website/static/img/logos/pypeclub_color_white.svg +++ b/website/static/img/logos/pypeclub_color_white.svg @@ -1,26 +1,40 @@ - - + + - + - + - + - .club + + + + + + + + + + + + + + + diff --git a/website/static/img/logos/pypeclub_white.svg b/website/static/img/logos/pypeclub_white.svg index b634c210b1..3bf4159f9c 100644 --- a/website/static/img/logos/pypeclub_white.svg +++ b/website/static/img/logos/pypeclub_white.svg @@ -1,8 +1,8 @@ - - + + @@ -20,7 +20,21 @@ - .club + + + + + + + + + + + + + + +