diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ed0159a4d..0f2cb2b1ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,67 @@ # Changelog -## [3.3.0-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.3.0-nightly.9](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.2.0...HEAD) +**🚀 Enhancements** + +- 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) +- TVPaint: Increment workfile [\#1885](https://github.com/pypeclub/OpenPype/pull/1885) +- Allow Multiple Notes to run on tasks. [\#1882](https://github.com/pypeclub/OpenPype/pull/1882) +- Prepare for pyside2 [\#1869](https://github.com/pypeclub/OpenPype/pull/1869) +- Filter hosts in settings host-enum [\#1868](https://github.com/pypeclub/OpenPype/pull/1868) +- Local actions with process identifier [\#1867](https://github.com/pypeclub/OpenPype/pull/1867) +- Workfile tool start at host launch support [\#1865](https://github.com/pypeclub/OpenPype/pull/1865) +- 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) +- Settings list can use template or schema as object type [\#1815](https://github.com/pypeclub/OpenPype/pull/1815) +- Maya: Deadline custom settings [\#1797](https://github.com/pypeclub/OpenPype/pull/1797) +- Maya: Shader name validation [\#1762](https://github.com/pypeclub/OpenPype/pull/1762) + **🐛 Bug fixes** -- Houdini colector formatting keys fix [\#1802](https://github.com/pypeclub/OpenPype/pull/1802) +- 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) +- 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) +- Settings error dialog on show [\#1798](https://github.com/pypeclub/OpenPype/pull/1798) **Merged pull requests:** -- Maya: Deadline custom settings [\#1797](https://github.com/pypeclub/OpenPype/pull/1797) +- 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) ## [3.2.0](https://github.com/pypeclub/OpenPype/tree/3.2.0) (2021-07-13) @@ -28,11 +79,6 @@ - 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) -- Validate containers settings [\#1736](https://github.com/pypeclub/OpenPype/pull/1736) -- PS - added loader from sequence [\#1726](https://github.com/pypeclub/OpenPype/pull/1726) -- Toggle Ftrack upload in StandalonePublisher [\#1708](https://github.com/pypeclub/OpenPype/pull/1708) **🐛 Bug fixes** @@ -48,82 +94,29 @@ - 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) -- Local settings UI crash on missing defaults [\#1737](https://github.com/pypeclub/OpenPype/pull/1737) -- TVPaint white background on thumbnail [\#1735](https://github.com/pypeclub/OpenPype/pull/1735) -- Application without executables [\#1679](https://github.com/pypeclub/OpenPype/pull/1679) -- Unreal: launching on Linux [\#1672](https://github.com/pypeclub/OpenPype/pull/1672) **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) -**🐛 Bug fixes** - -- Tools names forwards compatibility [\#1727](https://github.com/pypeclub/OpenPype/pull/1727) - -**⚠️ Deprecations** - -- global: removing obsolete ftrack validator plugin [\#1710](https://github.com/pypeclub/OpenPype/pull/1710) - ## [2.18.2](https://github.com/pypeclub/OpenPype/tree/2.18.2) (2021-06-16) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.1.0...2.18.2) -**🐛 Bug fixes** - -- Maya: Extract review hotfix - 2.x backport [\#1713](https://github.com/pypeclub/OpenPype/pull/1713) - -**Merged pull requests:** - -- 1698 Nuke: Prerender Frame Range by default [\#1709](https://github.com/pypeclub/OpenPype/pull/1709) - ## [3.1.0](https://github.com/pypeclub/OpenPype/tree/3.1.0) (2021-06-15) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.1.0-nightly.4...3.1.0) -**🚀 Enhancements** - -- Log Viewer with OpenPype style [\#1703](https://github.com/pypeclub/OpenPype/pull/1703) -- Scrolling in OpenPype info widget [\#1702](https://github.com/pypeclub/OpenPype/pull/1702) -- OpenPype style in modules [\#1694](https://github.com/pypeclub/OpenPype/pull/1694) -- Sort applications and tools alphabetically in Settings UI [\#1689](https://github.com/pypeclub/OpenPype/pull/1689) -- \#683 - Validate Frame Range in Standalone Publisher [\#1683](https://github.com/pypeclub/OpenPype/pull/1683) -- Hiero: old container versions identify with red color [\#1682](https://github.com/pypeclub/OpenPype/pull/1682) - -**🐛 Bug fixes** - -- Nuke: broken publishing rendered frames [\#1707](https://github.com/pypeclub/OpenPype/pull/1707) -- Standalone publisher Thumbnail export args [\#1705](https://github.com/pypeclub/OpenPype/pull/1705) -- Bad zip can break OpenPype start [\#1691](https://github.com/pypeclub/OpenPype/pull/1691) -- Hiero: published whole edit mov [\#1687](https://github.com/pypeclub/OpenPype/pull/1687) -- Ftrack subprocess handle of stdout/stderr [\#1675](https://github.com/pypeclub/OpenPype/pull/1675) - -**Merged pull requests:** - -- update dependencies [\#1697](https://github.com/pypeclub/OpenPype/pull/1697) -- Bump normalize-url from 4.5.0 to 4.5.1 in /website [\#1686](https://github.com/pypeclub/OpenPype/pull/1686) - # Changelog 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/__init__.py b/openpype/__init__.py index a86d2bc2be..e7462e14e9 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -98,6 +98,11 @@ def install(): .get(platform_name) ) or [] for path in project_plugins: + try: + path = str(path.format(**os.environ)) + except KeyError: + pass + if not path or not os.path.exists(path): continue diff --git a/openpype/hooks/pre_copy_template_workfile.py b/openpype/hooks/pre_copy_template_workfile.py index 29a522f933..5c56d721e8 100644 --- a/openpype/hooks/pre_copy_template_workfile.py +++ b/openpype/hooks/pre_copy_template_workfile.py @@ -49,7 +49,7 @@ class CopyTemplateWorkfile(PreLaunchHook): )) return - self.log.info("Last workfile does not exits.") + self.log.info("Last workfile does not exist.") project_name = self.data["project_name"] asset_name = self.data["asset_name"] diff --git a/openpype/hooks/pre_foundry_apps.py b/openpype/hooks/pre_foundry_apps.py new file mode 100644 index 0000000000..85f68c6b60 --- /dev/null +++ b/openpype/hooks/pre_foundry_apps.py @@ -0,0 +1,28 @@ +import subprocess +from openpype.lib import PreLaunchHook + + +class LaunchFoundryAppsWindows(PreLaunchHook): + """Foundry applications have specific way how to launch them. + + Nuke is executed "like" python process so it is required to pass + `CREATE_NEW_CONSOLE` flag on windows to trigger creation of new console. + At the same time the newly created console won't create it's own stdout + and stderr handlers so they should not be redirected to DEVNULL. + """ + + # Should be as last hook because must change launch arguments to string + order = 1000 + app_groups = ["nuke", "nukex", "hiero", "nukestudio"] + platforms = ["windows"] + + def execute(self): + # Change `creationflags` to CREATE_NEW_CONSOLE + # - on Windows will nuke create new window using it's console + # Set `stdout` and `stderr` to None so new created console does not + # have redirected output to DEVNULL in build + self.launch_context.kwargs.update({ + "creationflags": subprocess.CREATE_NEW_CONSOLE, + "stdout": None, + "stderr": None + }) diff --git a/openpype/hooks/pre_non_python_host_launch.py b/openpype/hooks/pre_non_python_host_launch.py index 393a878f76..0447f4a06f 100644 --- a/openpype/hooks/pre_non_python_host_launch.py +++ b/openpype/hooks/pre_non_python_host_launch.py @@ -49,5 +49,7 @@ class NonPythonHostHook(PreLaunchHook): if remainders: self.launch_context.launch_args.extend(remainders) + # This must be set otherwise it wouldn't be possible to catch output + # when build OpenPype is used. self.launch_context.kwargs["stdout"] = subprocess.DEVNULL - self.launch_context.kwargs["stderr"] = subprocess.STDOUT + self.launch_context.kwargs["stderr"] = subprocess.DEVNULL diff --git a/openpype/hooks/pre_with_windows_shell.py b/openpype/hooks/pre_with_windows_shell.py deleted file mode 100644 index 441ab1a675..0000000000 --- a/openpype/hooks/pre_with_windows_shell.py +++ /dev/null @@ -1,44 +0,0 @@ -import os -import subprocess -from openpype.lib import PreLaunchHook - - -class LaunchWithWindowsShell(PreLaunchHook): - """Add shell command before executable. - - Some hosts have issues when are launched directly from python in that case - it is possible to prepend shell executable which will trigger process - instead. - """ - - # Should be as last hook because must change launch arguments to string - order = 1000 - app_groups = ["nuke", "nukex", "hiero", "nukestudio"] - platforms = ["windows"] - - def execute(self): - launch_args = self.launch_context.clear_launch_args( - self.launch_context.launch_args) - new_args = [ - # Get comspec which is cmd.exe in most cases. - os.environ.get("COMSPEC", "cmd.exe"), - # NOTE change to "/k" if want to keep console opened - "/c", - # Convert arguments to command line arguments (as string) - "\"{}\"".format( - subprocess.list2cmdline(launch_args) - ) - ] - # Convert list to string - # WARNING this only works if is used as string - args_string = " ".join(new_args) - self.log.info(( - "Modified launch arguments to be launched with shell \"{}\"." - ).format(args_string)) - - # Replace launch args with new one - self.launch_context.launch_args = args_string - # Change `creationflags` to CREATE_NEW_CONSOLE - self.launch_context.kwargs["creationflags"] = ( - subprocess.CREATE_NEW_CONSOLE - ) 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/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_instance_asset.py b/openpype/hosts/aftereffects/plugins/publish/validate_instance_asset.py new file mode 100644 index 0000000000..eff89adcb3 --- /dev/null +++ b/openpype/hosts/aftereffects/plugins/publish/validate_instance_asset.py @@ -0,0 +1,61 @@ +from avalon import api +import pyblish.api +import openpype.api +from avalon import aftereffects + + +class ValidateInstanceAssetRepair(pyblish.api.Action): + """Repair the instance asset with value from Context.""" + + label = "Repair" + icon = "wrench" + on = "failed" + + def process(self, context, plugin): + + # Get the errored instances + failed = [] + for result in context.data["results"]: + if (result["error"] is not None and result["instance"] is not None + and result["instance"] not in failed): + failed.append(result["instance"]) + + # Apply pyblish.logic to get the instances for the plug-in + instances = pyblish.api.instances_by_plugin(failed, plugin) + stub = aftereffects.stub() + for instance in instances: + data = stub.read(instance[0]) + + data["asset"] = api.Session["AVALON_ASSET"] + stub.imprint(instance[0], data) + + +class ValidateInstanceAsset(pyblish.api.InstancePlugin): + """Validate the instance asset is the current selected context asset. + + As it might happen that multiple worfiles are opened at same time, + switching between them would mess with selected context. (From Launcher + or Ftrack). + + In that case outputs might be output under wrong asset! + + Repair action will use Context asset value (from Workfiles or Launcher) + Closing and reopening with Workfiles will refresh Context value. + """ + + label = "Validate Instance Asset" + hosts = ["aftereffects"] + actions = [ValidateInstanceAssetRepair] + order = openpype.api.ValidateContentsOrder + + def process(self, instance): + instance_asset = instance.data["asset"] + current_asset = api.Session["AVALON_ASSET"] + msg = ( + f"Instance asset {instance_asset} is not the same " + f"as current context {current_asset}. PLEASE DO:\n" + f"Repair with 'A' action to use '{current_asset}'.\n" + f"If that's not correct value, close workfile and " + f"reopen via Workfiles!" + ) + assert instance_asset == current_asset, msg 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/commands.py b/openpype/hosts/maya/api/commands.py new file mode 100644 index 0000000000..d4c2b6a225 --- /dev/null +++ b/openpype/hosts/maya/api/commands.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +"""OpenPype script commands to be used directly in Maya.""" + + +class ToolWindows: + + _windows = {} + + @classmethod + def get_window(cls, tool): + """Get widget for specific tool. + + Args: + tool (str): Name of the tool. + + Returns: + Stored widget. + + """ + try: + return cls._windows[tool] + except KeyError: + return None + + @classmethod + def set_window(cls, tool, window): + """Set widget for the tool. + + Args: + tool (str): Name of the tool. + window (QtWidgets.QWidget): Widget + + """ + cls._windows[tool] = window + + +def edit_shader_definitions(): + from avalon.tools import lib + from Qt import QtWidgets + from openpype.hosts.maya.api.shader_definition_editor import ( + ShaderDefinitionsEditor + ) + + top_level_widgets = QtWidgets.QApplication.topLevelWidgets() + main_window = next(widget for widget in top_level_widgets + if widget.objectName() == "MayaWindow") + + with lib.application(): + window = ToolWindows.get_window("shader_definition_editor") + if not window: + window = ShaderDefinitionsEditor(parent=main_window) + ToolWindows.set_window("shader_definition_editor", window) + window.show() diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index 42e5c66e4a..0dced48868 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -6,9 +6,9 @@ from avalon.vendor.Qt import QtWidgets, QtGui from avalon.maya import pipeline from openpype.api import BuildWorkfile import maya.cmds as cmds +from openpype.settings import get_project_settings self = sys.modules[__name__] -self._menu = os.environ.get("AVALON_LABEL") log = logging.getLogger(__name__) @@ -17,8 +17,11 @@ 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 = self._menu + menu_name = _menu widgets = dict(( w.objectName(), w) for w in QtWidgets.QApplication.allWidgets()) menu = widgets.get(menu_name) @@ -55,35 +58,7 @@ def deferred(): parent=pipeline._parent ) - # Find the pipeline menu - top_menu = _get_menu(pipeline._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) - - log.info("Attempting to install scripts menu..") + log.info("Attempting to install scripts menu ...") add_build_workfiles_item() add_look_assigner_item() @@ -100,13 +75,18 @@ def deferred(): return # load configuration of custom menu - config_path = os.path.join(os.path.dirname(__file__), "menu.json") - config = scriptsmenu.load_configuration(config_path) + project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) + config = project_settings["maya"]["scriptsmenu"]["definition"] + _menu = project_settings["maya"]["scriptsmenu"]["name"] + + if not config: + log.warning("Skipping studio menu, no definition found.") + return # run the launcher for Maya menu studio_menu = launchformaya.main( - title=self._menu.title(), - objectName=self._menu + title=_menu.title(), + objectName=_menu.title().lower().replace(" ", "_") ) # apply configuration @@ -116,7 +96,7 @@ def deferred(): def uninstall(): menu = _get_menu() if menu: - log.info("Attempting to uninstall..") + log.info("Attempting to uninstall ...") try: menu.deleteLater() @@ -136,9 +116,8 @@ def install(): def popup(): - """Pop-up the existing menu near the mouse cursor""" + """Pop-up the existing menu near the mouse cursor.""" menu = _get_menu() - cursor = QtGui.QCursor() point = cursor.pos() menu.exec_(point) diff --git a/openpype/hosts/maya/api/shader_definition_editor.py b/openpype/hosts/maya/api/shader_definition_editor.py new file mode 100644 index 0000000000..73cc6246ab --- /dev/null +++ b/openpype/hosts/maya/api/shader_definition_editor.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +"""Editor for shader definitions. + +Shader names are stored as simple text file over GridFS in mongodb. + +""" +import os +from Qt import QtWidgets, QtCore, QtGui +from openpype.lib.mongo import OpenPypeMongoConnection +from openpype import resources +import gridfs + + +DEFINITION_FILENAME = "{}/maya/shader_definition.txt".format( + os.getenv("AVALON_PROJECT")) + + +class ShaderDefinitionsEditor(QtWidgets.QWidget): + """Widget serving as simple editor for shader name definitions.""" + + # name of the file used to store definitions + + def __init__(self, parent=None): + super(ShaderDefinitionsEditor, self).__init__(parent) + self._mongo = OpenPypeMongoConnection.get_mongo_client() + self._gridfs = gridfs.GridFS( + self._mongo[os.getenv("OPENPYPE_DATABASE_NAME")]) + self._editor = None + + self._original_content = self._read_definition_file() + + self.setObjectName("shaderDefinitionEditor") + self.setWindowTitle("OpenPype shader name definition editor") + icon = QtGui.QIcon(resources.pype_icon_filepath()) + self.setWindowIcon(icon) + self.setWindowFlags(QtCore.Qt.Window) + self.setParent(parent) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.resize(750, 500) + + self._setup_ui() + self._reload() + + def _setup_ui(self): + """Setup UI of Widget.""" + layout = QtWidgets.QVBoxLayout(self) + label = QtWidgets.QLabel() + label.setText("Put shader names here - one name per line:") + layout.addWidget(label) + self._editor = QtWidgets.QPlainTextEdit() + self._editor.setStyleSheet("border: none;") + layout.addWidget(self._editor) + + btn_layout = QtWidgets.QHBoxLayout() + save_btn = QtWidgets.QPushButton("Save") + save_btn.clicked.connect(self._save) + + reload_btn = QtWidgets.QPushButton("Reload") + reload_btn.clicked.connect(self._reload) + + exit_btn = QtWidgets.QPushButton("Exit") + exit_btn.clicked.connect(self._close) + + btn_layout.addWidget(reload_btn) + btn_layout.addWidget(save_btn) + btn_layout.addWidget(exit_btn) + + layout.addLayout(btn_layout) + + def _read_definition_file(self, file=None): + """Read definition file from database. + + Args: + file (gridfs.grid_file.GridOut, Optional): File to read. If not + set, new query will be issued to find it. + + Returns: + str: Content of the file or empty string if file doesn't exist. + + """ + content = "" + if not file: + file = self._gridfs.find_one( + {"filename": DEFINITION_FILENAME}) + if not file: + print(">>> [SNDE]: nothing in database yet") + return content + content = file.read() + file.close() + return content + + def _write_definition_file(self, content, force=False): + """Write content as definition to file in database. + + Before file is writen, check is made if its content has not + changed. If is changed, warning is issued to user if he wants + it to overwrite. Note: GridFs doesn't allow changing file content. + You need to delete existing file and create new one. + + Args: + content (str): Content to write. + + Raises: + ContentException: If file is changed in database while + editor is running. + """ + file = self._gridfs.find_one( + {"filename": DEFINITION_FILENAME}) + if file: + content_check = self._read_definition_file(file) + if content == content_check: + print(">>> [SNDE]: content not changed") + return + if self._original_content != content_check: + if not force: + raise ContentException("Content changed") + print(">>> [SNDE]: overwriting data") + file.close() + self._gridfs.delete(file._id) + + file = self._gridfs.new_file( + filename=DEFINITION_FILENAME, + content_type='text/plain', + encoding='utf-8') + file.write(content) + file.close() + QtCore.QTimer.singleShot(200, self._reset_style) + self._editor.setStyleSheet("border: 1px solid #33AF65;") + self._original_content = content + + def _reset_style(self): + """Reset editor style back. + + Used to visually indicate save. + + """ + self._editor.setStyleSheet("border: none;") + + def _close(self): + self.hide() + + def closeEvent(self, event): + event.ignore() + self.hide() + + def _reload(self): + print(">>> [SNDE]: reloading") + self._set_content(self._read_definition_file()) + + def _save(self): + try: + self._write_definition_file(content=self._editor.toPlainText()) + except ContentException: + # content has changed meanwhile + print(">>> [SNDE]: content has changed") + self._show_overwrite_warning() + + def _set_content(self, content): + self._editor.setPlainText(content) + + def _show_overwrite_warning(self): + reply = QtWidgets.QMessageBox.question( + self, + "Warning", + ("Content you are editing was changed meanwhile in database.\n" + "Please, reload and solve the conflict."), + QtWidgets.QMessageBox.OK) + + if reply == QtWidgets.QMessageBox.OK: + # do nothing + pass + + +class ContentException(Exception): + """This is risen during save if file is changed in database.""" + pass diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index bf24b463ac..0dde52447d 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -167,6 +167,8 @@ def get_file_node_path(node): if cmds.nodeType(node) == 'aiImage': return cmds.getAttr('{0}.filename'.format(node)) + if cmds.nodeType(node) == 'RedshiftNormalMap': + return cmds.getAttr('{}.tex0'.format(node)) # otherwise use fileTextureName return cmds.getAttr('{0}.fileTextureName'.format(node)) @@ -357,6 +359,7 @@ class CollectLook(pyblish.api.InstancePlugin): files = cmds.ls(history, type="file", long=True) files.extend(cmds.ls(history, type="aiImage", long=True)) + files.extend(cmds.ls(history, type="RedshiftNormalMap", long=True)) self.log.info("Collected file nodes:\n{}".format(files)) # Collect textures if any file nodes are found @@ -487,7 +490,7 @@ class CollectLook(pyblish.api.InstancePlugin): """ self.log.debug("processing: {}".format(node)) - if cmds.nodeType(node) not in ["file", "aiImage"]: + if cmds.nodeType(node) not in ["file", "aiImage", "RedshiftNormalMap"]: self.log.error( "Unsupported file node: {}".format(cmds.nodeType(node))) raise AssertionError("Unsupported file node") @@ -500,11 +503,19 @@ class CollectLook(pyblish.api.InstancePlugin): self.log.debug("aiImage node") attribute = "{}.filename".format(node) computed_attribute = attribute + elif cmds.nodeType(node) == 'RedshiftNormalMap': + self.log.debug("RedshiftNormalMap node") + attribute = "{}.tex0".format(node) + computed_attribute = attribute source = cmds.getAttr(attribute) self.log.info(" - file source: {}".format(source)) color_space_attr = "{}.colorSpace".format(node) - color_space = cmds.getAttr(color_space_attr) + try: + color_space = cmds.getAttr(color_space_attr) + except ValueError: + # node doesn't have colorspace attribute + color_space = "raw" # Compare with the computed file path, e.g. the one with the # pattern in it, to generate some logging information about this # difference diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index bdd061578e..f09d50d714 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -233,11 +233,14 @@ class ExtractLook(openpype.api.Extractor): for filepath in files_metadata: linearize = False - if do_maketx and files_metadata[filepath]["color_space"] == "sRGB": # noqa: E501 + if do_maketx and files_metadata[filepath]["color_space"].lower() == "srgb": # noqa: E501 linearize = True # set its file node to 'raw' as tx will be linearized files_metadata[filepath]["color_space"] = "raw" + if do_maketx: + color_space = "raw" + source, mode, texture_hash = self._process_texture( filepath, do_maketx, @@ -280,14 +283,19 @@ class ExtractLook(openpype.api.Extractor): # This will also trigger in the same order at end of context to # ensure after context it's still the original value. color_space_attr = resource["node"] + ".colorSpace" - color_space = cmds.getAttr(color_space_attr) - if files_metadata[source]["color_space"] == "raw": - # set color space to raw if we linearized it - color_space = "Raw" - # Remap file node filename to destination + try: + color_space = cmds.getAttr(color_space_attr) + except ValueError: + # node doesn't have color space attribute + color_space = "raw" + else: + if files_metadata[source]["color_space"] == "raw": + # set color space to raw if we linearized it + color_space = "raw" + # Remap file node filename to destination + remap[color_space_attr] = color_space attr = resource["attribute"] remap[attr] = destinations[source] - remap[color_space_attr] = color_space self.log.info("Finished remapping destinations ...") diff --git a/openpype/hosts/maya/plugins/publish/validate_model_name.py b/openpype/hosts/maya/plugins/publish/validate_model_name.py index 98da4d42ba..3757e13a9b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_model_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_model_name.py @@ -1,8 +1,16 @@ +# -*- coding: utf-8 -*- +"""Validate model nodes names.""" from maya import cmds import pyblish.api import openpype.api +import avalon.api import openpype.hosts.maya.api.action +from openpype.hosts.maya.api.shader_definition_editor import ( + DEFINITION_FILENAME) +from openpype.lib.mongo import OpenPypeMongoConnection +import gridfs import re +import os class ValidateModelName(pyblish.api.InstancePlugin): @@ -19,18 +27,18 @@ class ValidateModelName(pyblish.api.InstancePlugin): families = ["model"] label = "Model Name" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] - # path to shader names definitions - # TODO: move it to preset file material_file = None - regex = '(.*)_(\\d)*_(.*)_(GEO)' + database_file = DEFINITION_FILENAME @classmethod def get_invalid(cls, instance): + """Get invalid nodes.""" + use_db = cls.database - # find out if supplied transform is group or not - def is_group(groupName): + def is_group(group_name): + """Find out if supplied transform is group or not.""" try: - children = cmds.listRelatives(groupName, children=True) + children = cmds.listRelatives(group_name, children=True) for child in children: if not cmds.ls(child, transforms=True): return False @@ -44,29 +52,74 @@ class ValidateModelName(pyblish.api.InstancePlugin): cls.log.error("Instance has no nodes!") return True pass + + # validate top level group name + assemblies = cmds.ls(content_instance, assemblies=True, long=True) + if len(assemblies) != 1: + cls.log.error("Must have exactly one top group") + return assemblies or True + top_group = assemblies[0] + regex = cls.top_level_regex + r = re.compile(regex) + m = r.match(top_group) + if m is None: + cls.log.error("invalid name on: {}".format(top_group)) + cls.log.error("name doesn't match regex {}".format(regex)) + invalid.append(top_group) + else: + if "asset" in r.groupindex: + if m.group("asset") != avalon.api.Session["AVALON_ASSET"]: + cls.log.error("Invalid asset name in top level group.") + return top_group + if "subset" in r.groupindex: + if m.group("subset") != instance.data.get("subset"): + cls.log.error("Invalid subset name in top level group.") + return top_group + if "project" in r.groupindex: + if m.group("project") != avalon.api.Session["AVALON_PROJECT"]: + cls.log.error("Invalid project name in top level group.") + return top_group + descendants = cmds.listRelatives(content_instance, allDescendents=True, fullPath=True) or [] descendants = cmds.ls(descendants, noIntermediate=True, long=True) - trns = cmds.ls(descendants, long=False, type=('transform')) + trns = cmds.ls(descendants, long=False, type='transform') # filter out groups - filter = [node for node in trns if not is_group(node)] + filtered = [node for node in trns if not is_group(node)] # load shader list file as utf-8 - if cls.material_file: - shader_file = open(cls.material_file, "r") - shaders = shader_file.readlines() + shaders = [] + if not use_db: + if cls.material_file: + if os.path.isfile(cls.material_file): + shader_file = open(cls.material_file, "r") + shaders = shader_file.readlines() + shader_file.close() + else: + cls.log.error("Missing shader name definition file.") + return True + else: + client = OpenPypeMongoConnection.get_mongo_client() + fs = gridfs.GridFS(client[os.getenv("OPENPYPE_DATABASE_NAME")]) + shader_file = fs.find_one({"filename": cls.database_file}) + if not shader_file: + cls.log.error("Missing shader name definition in database.") + return True + shaders = shader_file.read().splitlines() shader_file.close() # strip line endings from list shaders = map(lambda s: s.rstrip(), shaders) # compile regex for testing names - r = re.compile(cls.regex) + regex = cls.regex + r = re.compile(regex) - for obj in filter: + for obj in filtered: + cls.log.info("testing: {}".format(obj)) m = r.match(obj) if m is None: cls.log.error("invalid name on: {}".format(obj)) @@ -74,7 +127,7 @@ class ValidateModelName(pyblish.api.InstancePlugin): else: # if we have shader files and shader named group is in # regex, test this group against names in shader file - if 'shader' in r.groupindex and shaders: + if "shader" in r.groupindex and shaders: try: if not m.group('shader') in shaders: cls.log.error( @@ -90,8 +143,8 @@ class ValidateModelName(pyblish.api.InstancePlugin): return invalid def process(self, instance): - + """Plugin entry point.""" invalid = self.get_invalid(instance) if invalid: - raise RuntimeError("Model naming is invalid. See log.") + raise RuntimeError("Model naming is invalid. See the log.") diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index d7f3fdc6ba..7e7cd27f90 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -113,6 +113,14 @@ def check_inventory_versions(): "_id": io.ObjectId(avalon_knob_data["representation"]) }) + # Failsafe for not finding the representation. + if not representation: + log.warning( + "Could not find the representation on " + "node \"{}\"".format(node.name()) + ) + continue + # Get start frame from version data version = io.find_one({ "type": "version", @@ -391,13 +399,14 @@ def create_write_node(name, data, input=None, prenodes=None, if prenodes: for node in prenodes: # get attributes - name = node["name"] + pre_node_name = node["name"] klass = node["class"] knobs = node["knobs"] dependent = node["dependent"] # create node - now_node = nuke.createNode(klass, "name {}".format(name)) + now_node = nuke.createNode( + klass, "name {}".format(pre_node_name)) now_node.hideControlPanel() # add data to knob @@ -476,27 +485,27 @@ def create_write_node(name, data, input=None, prenodes=None, linked_knob_names.append("Render") - for name in linked_knob_names: - if "_grp-start_" in name: + for _k_name in linked_knob_names: + if "_grp-start_" in _k_name: knob = nuke.Tab_Knob( "rnd_attr", "Rendering attributes", nuke.TABBEGINCLOSEDGROUP) GN.addKnob(knob) - elif "_grp-end_" in name: + elif "_grp-end_" in _k_name: knob = nuke.Tab_Knob( "rnd_attr_end", "Rendering attributes", nuke.TABENDGROUP) GN.addKnob(knob) else: - if "___" in name: + if "___" in _k_name: # add devider GN.addKnob(nuke.Text_Knob("")) else: - # add linked knob by name + # add linked knob by _k_name link = nuke.Link_Knob("") - link.makeLink(write_node.name(), name) - link.setName(name) + link.makeLink(write_node.name(), _k_name) + link.setName(_k_name) # make render - if "Render" in name: + if "Render" in _k_name: link.setLabel("Render Local") link.setFlag(0x1000) GN.addKnob(link) @@ -1651,9 +1660,13 @@ def find_free_space_to_paste_nodes( def launch_workfiles_app(): '''Function letting start workfiles after start of host ''' - # get state from settings - open_at_start = get_current_project_settings()["nuke"].get( - "general", {}).get("open_workfile_at_start") + from openpype.lib import ( + env_value_to_bool + ) + # get all imortant settings + open_at_start = env_value_to_bool( + env_key="OPENPYPE_WORKFILE_TOOL_ON_START", + default=None) # return if none is defined if not open_at_start: @@ -1730,3 +1743,68 @@ def process_workfile_builder(): log.info("Opening last workfile...") # open workfile open_file(last_workfile_path) + + +def recreate_instance(origin_node, avalon_data=None): + """Recreate input instance to different data + + Args: + origin_node (nuke.Node): Nuke node to be recreating from + avalon_data (dict, optional): data to be used in new node avalon_data + + Returns: + nuke.Node: newly created node + """ + knobs_wl = ["render", "publish", "review", "ypos", + "use_limit", "first", "last"] + # get data from avalon knobs + data = anlib.get_avalon_knob_data( + origin_node) + + # add input data to avalon data + if avalon_data: + data.update(avalon_data) + + # capture all node knobs allowed in op_knobs + knobs_data = {k: origin_node[k].value() + for k in origin_node.knobs() + for key in knobs_wl + if key in k} + + # get node dependencies + inputs = origin_node.dependencies() + outputs = origin_node.dependent() + + # remove the node + nuke.delete(origin_node) + + # create new node + # get appropriate plugin class + creator_plugin = None + for Creator in api.discover(api.Creator): + if Creator.__name__ == data["creator"]: + creator_plugin = Creator + break + + # create write node with creator + new_node_name = data["subset"] + new_node = creator_plugin(new_node_name, data["asset"]).process() + + # white listed knobs to the new node + for _k, _v in knobs_data.items(): + try: + print(_k, _v) + new_node[_k].setValue(_v) + except Exception as e: + print(e) + + # connect to original inputs + for i, n in enumerate(inputs): + new_node.setInput(i, n) + + # connect to outputs + if len(outputs) > 0: + for dn in outputs: + dn.setInput(0, new_node) + + return new_node diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index 6306767f37..8ba1b6b7c1 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -214,7 +214,7 @@ class LoadEffects(api.Loader): self.log.warning(e) continue - if isinstance(v, list) and len(v) > 3: + if isinstance(v, list) and len(v) > 4: node[k].setAnimated() for i, value in enumerate(v): if isinstance(value, list): diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index 6c71f2ae16..d0cab26842 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -217,7 +217,7 @@ class LoadEffectsInputProcess(api.Loader): self.log.warning(e) continue - if isinstance(v, list) and len(v) > 3: + if isinstance(v, list) and len(v) > 4: node[k].setAnimated() for i, value in enumerate(v): if isinstance(value, list): @@ -239,10 +239,10 @@ class LoadEffectsInputProcess(api.Loader): output = nuke.createNode("Output") output.setInput(0, pre_node) - # try to place it under Viewer1 - if not self.connect_active_viewer(GN): - nuke.delete(GN) - return + # # try to place it under Viewer1 + # if not self.connect_active_viewer(GN): + # nuke.delete(GN) + # return # get all versions in list versions = io.find({ @@ -298,7 +298,11 @@ class LoadEffectsInputProcess(api.Loader): viewer["input_process_node"].setValue(group_node_name) # put backdrop under - lib.create_backdrop(label="Input Process", layer=2, nodes=[viewer, group_node], color="0x7c7faaff") + lib.create_backdrop( + label="Input Process", + layer=2, + nodes=[viewer, group_node], + color="0x7c7faaff") return True 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/photoshop/plugins/publish/validate_instance_asset.py b/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py index a1de02f319..4dc1972074 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py @@ -1,5 +1,4 @@ -import os - +from avalon import api import pyblish.api import openpype.api from avalon import photoshop @@ -27,12 +26,20 @@ class ValidateInstanceAssetRepair(pyblish.api.Action): for instance in instances: data = stub.read(instance[0]) - data["asset"] = os.environ["AVALON_ASSET"] + data["asset"] = api.Session["AVALON_ASSET"] stub.imprint(instance[0], data) class ValidateInstanceAsset(pyblish.api.InstancePlugin): - """Validate the instance asset is the current asset.""" + """Validate the instance asset is the current selected context asset. + + As it might happen that multiple worfiles are opened, switching + between them would mess with selected context. + In that case outputs might be output under wrong asset! + + Repair action will use Context asset value (from Workfiles or Launcher) + Closing and reopening with Workfiles will refresh Context value. + """ label = "Validate Instance Asset" hosts = ["photoshop"] @@ -41,9 +48,12 @@ class ValidateInstanceAsset(pyblish.api.InstancePlugin): def process(self, instance): instance_asset = instance.data["asset"] - current_asset = os.environ["AVALON_ASSET"] + current_asset = api.Session["AVALON_ASSET"] msg = ( - "Instance asset is not the same as current asset:" - f"\nInstance: {instance_asset}\nCurrent: {current_asset}" + f"Instance asset {instance_asset} is not the same " + f"as current context {current_asset}. PLEASE DO:\n" + f"Repair with 'A' action to use '{current_asset}'.\n" + f"If that's not correct value, close workfile and " + f"reopen via Workfiles!" ) assert instance_asset == current_asset, msg diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py index dbf2574a9d..3a9a7a3445 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py @@ -181,7 +181,8 @@ class CollectInstances(pyblish.api.InstancePlugin): } }) for subset, properities in self.subsets.items(): - if properities["version"] == 0: + version = properities.get("version") + if version == 0: properities.pop("version") # adding Review-able instance diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_hierarchy.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_hierarchy.py index ba2aed4bfc..acad98d784 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_hierarchy.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_hierarchy.py @@ -37,7 +37,7 @@ class CollectHierarchyInstance(pyblish.api.ContextPlugin): # return if any if entity_type: - return {"entityType": entity_type, "entityName": value} + return {"entity_type": entity_type, "entity_name": value} def rename_with_hierarchy(self, instance): search_text = "" @@ -76,8 +76,8 @@ class CollectHierarchyInstance(pyblish.api.ContextPlugin): # add current selection context hierarchy from standalonepublisher for entity in reversed(visual_hierarchy): parents.append({ - "entityType": entity["data"]["entityType"], - "entityName": entity["name"] + "entity_type": entity["data"]["entityType"], + "entity_name": entity["name"] }) if self.shot_add_hierarchy: @@ -98,7 +98,7 @@ class CollectHierarchyInstance(pyblish.api.ContextPlugin): # in case SP context is set to the same folder if (_index == 0) and ("folder" in parent_key) \ - and (parents[-1]["entityName"] == parent_filled): + and (parents[-1]["entity_name"] == parent_filled): self.log.debug(f" skiping : {parent_filled}") continue @@ -280,9 +280,9 @@ class CollectHierarchyContext(pyblish.api.ContextPlugin): for parent in reversed(parents): next_dict = {} - parent_name = parent["entityName"] + parent_name = parent["entity_name"] next_dict[parent_name] = {} - next_dict[parent_name]["entity_type"] = parent["entityType"] + next_dict[parent_name]["entity_type"] = parent["entity_type"] next_dict[parent_name]["childs"] = actual actual = next_dict diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py new file mode 100644 index 0000000000..596a8ccfd2 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py @@ -0,0 +1,457 @@ +import os +import re +import pyblish.api +import json + +from avalon.api import format_template_with_optional_keys + +from openpype.lib import prepare_template_data + + +class CollectTextures(pyblish.api.ContextPlugin): + """Collect workfile (and its resource_files) and textures. + + Currently implements use case with Mari and Substance Painter, where + one workfile is main (.mra - Mari) with possible additional workfiles + (.spp - Substance) + + + Provides: + 1 instance per workfile (with 'resources' filled if needed) + (workfile family) + 1 instance per group of textures + (textures family) + """ + + order = pyblish.api.CollectorOrder + label = "Collect Textures" + hosts = ["standalonepublisher"] + families = ["texture_batch"] + actions = [] + + # from presets + main_workfile_extensions = ['mra'] + other_workfile_extensions = ['spp', 'psd'] + texture_extensions = ["exr", "dpx", "jpg", "jpeg", "png", "tiff", "tga", + "gif", "svg"] + + # additional families (ftrack etc.) + workfile_families = [] + textures_families = [] + + color_space = ["linsRGB", "raw", "acesg"] + + # currently implemented placeholders ["color_space"] + # describing patterns in file names splitted by regex groups + input_naming_patterns = { + # workfile: corridorMain_v001.mra > + # texture: corridorMain_aluminiumID_v001_baseColor_linsRGB_1001.exr + "workfile": r'^([^.]+)(_[^_.]*)?_v([0-9]{3,}).+', + "textures": r'^([^_.]+)_([^_.]+)_v([0-9]{3,})_([^_.]+)_({color_space})_(1[0-9]{3}).+', # noqa + } + # matching regex group position to 'input_naming_patterns' + input_naming_groups = { + "workfile": ('asset', 'filler', 'version'), + "textures": ('asset', 'shader', 'version', 'channel', 'color_space', + 'udim') + } + + workfile_subset_template = "textures{Subset}Workfile" + # implemented keys: ["color_space", "channel", "subset", "shader"] + texture_subset_template = "textures{Subset}_{Shader}_{Channel}" + + def process(self, context): + self.context = context + + resource_files = {} + workfile_files = {} + representations = {} + version_data = {} + asset_builds = set() + asset = None + for instance in context: + if not self.input_naming_patterns: + raise ValueError("Naming patterns are not configured. \n" + "Ask admin to provide naming conventions " + "for workfiles and textures.") + + if not asset: + asset = instance.data["asset"] # selected from SP + + parsed_subset = instance.data["subset"].replace( + instance.data["family"], '') + + fill_pairs = { + "subset": parsed_subset + } + + fill_pairs = prepare_template_data(fill_pairs) + workfile_subset = format_template_with_optional_keys( + fill_pairs, self.workfile_subset_template) + + processed_instance = False + for repre in instance.data["representations"]: + ext = repre["ext"].replace('.', '') + asset_build = version = None + + if isinstance(repre["files"], list): + repre_file = repre["files"][0] + else: + repre_file = repre["files"] + + if ext in self.main_workfile_extensions or \ + ext in self.other_workfile_extensions: + + asset_build = self._get_asset_build( + repre_file, + self.input_naming_patterns["workfile"], + self.input_naming_groups["workfile"], + self.color_space + ) + version = self._get_version( + repre_file, + self.input_naming_patterns["workfile"], + self.input_naming_groups["workfile"], + self.color_space + ) + asset_builds.add((asset_build, version, + workfile_subset, 'workfile')) + processed_instance = True + + if not representations.get(workfile_subset): + representations[workfile_subset] = [] + + if ext in self.main_workfile_extensions: + # workfiles can have only single representation + # currently OP is not supporting different extensions in + # representation files + representations[workfile_subset] = [repre] + + workfile_files[asset_build] = repre_file + + if ext in self.other_workfile_extensions: + # add only if not added already from main + if not representations.get(workfile_subset): + representations[workfile_subset] = [repre] + + # only overwrite if not present + if not workfile_files.get(asset_build): + workfile_files[asset_build] = repre_file + + if not resource_files.get(workfile_subset): + resource_files[workfile_subset] = [] + item = { + "files": [os.path.join(repre["stagingDir"], + repre["files"])], + "source": "standalone publisher" + } + resource_files[workfile_subset].append(item) + + if ext in self.texture_extensions: + c_space = self._get_color_space( + repre_file, + self.color_space + ) + + channel = self._get_channel_name( + repre_file, + self.input_naming_patterns["textures"], + self.input_naming_groups["textures"], + self.color_space + ) + + shader = self._get_shader_name( + repre_file, + self.input_naming_patterns["textures"], + self.input_naming_groups["textures"], + self.color_space + ) + + formatting_data = { + "color_space": c_space or '', # None throws exception + "channel": channel or '', + "shader": shader or '', + "subset": parsed_subset or '' + } + + fill_pairs = prepare_template_data(formatting_data) + subset = format_template_with_optional_keys( + fill_pairs, self.texture_subset_template) + + asset_build = self._get_asset_build( + repre_file, + self.input_naming_patterns["textures"], + self.input_naming_groups["textures"], + self.color_space + ) + version = self._get_version( + repre_file, + self.input_naming_patterns["textures"], + self.input_naming_groups["textures"], + self.color_space + ) + if not representations.get(subset): + representations[subset] = [] + representations[subset].append(repre) + + ver_data = { + "color_space": c_space or '', + "channel_name": channel or '', + "shader_name": shader or '' + } + version_data[subset] = ver_data + + asset_builds.add( + (asset_build, version, subset, "textures")) + processed_instance = True + + if processed_instance: + self.context.remove(instance) + + self._create_new_instances(context, + asset, + asset_builds, + resource_files, + representations, + version_data, + workfile_files) + + def _create_new_instances(self, context, asset, asset_builds, + resource_files, representations, + version_data, workfile_files): + """Prepare new instances from collected data. + + Args: + context (ContextPlugin) + asset (string): selected asset from SP + asset_builds (set) of tuples + (asset_build, version, subset, family) + resource_files (list) of resource dicts - to store additional + files to main workfile + representations (list) of dicts - to store workfile info OR + all collected texture files, key is asset_build + version_data (dict) - prepared to store into version doc in DB + workfile_files (dict) - to store workfile to add to textures + key is asset_build + """ + # sort workfile first + asset_builds = sorted(asset_builds, + key=lambda tup: tup[3], reverse=True) + + # workfile must have version, textures might + main_version = None + for asset_build, version, subset, family in asset_builds: + if not main_version: + main_version = version + new_instance = context.create_instance(subset) + new_instance.data.update( + { + "subset": subset, + "asset": asset, + "label": subset, + "name": subset, + "family": family, + "version": int(version or main_version or 1), + "asset_build": asset_build # remove in validator + } + ) + + workfile = workfile_files.get(asset_build) + + if resource_files.get(subset): + # add resources only when workfile is main style + for ext in self.main_workfile_extensions: + if ext in workfile: + new_instance.data.update({ + "resources": resource_files.get(subset) + }) + break + + # store origin + if family == 'workfile': + families = self.workfile_families + families.append("texture_batch_workfile") + + new_instance.data["source"] = "standalone publisher" + else: + families = self.textures_families + + repre = representations.get(subset)[0] + new_instance.context.data["currentFile"] = os.path.join( + repre["stagingDir"], workfile or 'dummy.txt') + + new_instance.data["families"] = families + + # add data for version document + ver_data = version_data.get(subset) + if ver_data: + if workfile: + ver_data['workfile'] = workfile + + new_instance.data.update( + {"versionData": ver_data} + ) + + upd_representations = representations.get(subset) + if upd_representations and family != 'workfile': + upd_representations = self._update_representations( + upd_representations) + + new_instance.data["representations"] = upd_representations + + self.log.debug("new instance - {}:: {}".format( + family, + json.dumps(new_instance.data, indent=4))) + + def _get_asset_build(self, name, + input_naming_patterns, input_naming_groups, + color_spaces): + """Loops through configured workfile patterns to find asset name. + + Asset name used to bind workfile and its textures. + + Args: + name (str): workfile name + input_naming_patterns (list): + [workfile_pattern] or [texture_pattern] + input_naming_groups (list) + ordinal position of regex groups matching to input_naming.. + color_spaces (list) - predefined color spaces + """ + asset_name = "NOT_AVAIL" + + return self._parse(name, input_naming_patterns, input_naming_groups, + color_spaces, 'asset') or asset_name + + def _get_version(self, name, input_naming_patterns, input_naming_groups, + color_spaces): + found = self._parse(name, input_naming_patterns, input_naming_groups, + color_spaces, 'version') + + if found: + return found.replace('v', '') + + self.log.info("No version found in the name {}".format(name)) + + def _get_udim(self, name, input_naming_patterns, input_naming_groups, + color_spaces): + """Parses from 'name' udim value.""" + found = self._parse(name, input_naming_patterns, input_naming_groups, + color_spaces, 'udim') + if found: + return found + + self.log.warning("Didn't find UDIM in {}".format(name)) + + def _get_color_space(self, name, color_spaces): + """Looks for color_space from a list in a file name. + + Color space seems not to be recognizable by regex pattern, set of + known space spaces must be provided. + """ + color_space = None + found = [cs for cs in color_spaces if + re.search("_{}_".format(cs), name)] + + if not found: + self.log.warning("No color space found in {}".format(name)) + else: + if len(found) > 1: + msg = "Multiple color spaces found in {}->{}".format(name, + found) + self.log.warning(msg) + + color_space = found[0] + + return color_space + + def _get_shader_name(self, name, input_naming_patterns, + input_naming_groups, color_spaces): + """Return parsed shader name. + + Shader name is needed for overlapping udims (eg. udims might be + used for different materials, shader needed to not overwrite). + + Unknown format of channel name and color spaces >> cs are known + list - 'color_space' used as a placeholder + """ + found = self._parse(name, input_naming_patterns, input_naming_groups, + color_spaces, 'shader') + if found: + return found + + self.log.warning("Didn't find shader in {}".format(name)) + + def _get_channel_name(self, name, input_naming_patterns, + input_naming_groups, color_spaces): + """Return parsed channel name. + + Unknown format of channel name and color spaces >> cs are known + list - 'color_space' used as a placeholder + """ + found = self._parse(name, input_naming_patterns, input_naming_groups, + color_spaces, 'channel') + if found: + return found + + self.log.warning("Didn't find channel in {}".format(name)) + + def _parse(self, name, input_naming_patterns, input_naming_groups, + color_spaces, key): + """Universal way to parse 'name' with configurable regex groups. + + Args: + name (str): workfile name + input_naming_patterns (list): + [workfile_pattern] or [texture_pattern] + input_naming_groups (list) + ordinal position of regex groups matching to input_naming.. + color_spaces (list) - predefined color spaces + + Raises: + ValueError - if broken 'input_naming_groups' + """ + for input_pattern in input_naming_patterns: + for cs in color_spaces: + pattern = input_pattern.replace('{color_space}', cs) + regex_result = re.findall(pattern, name) + if regex_result: + idx = list(input_naming_groups).index(key) + if idx < 0: + msg = "input_naming_groups must " +\ + "have '{}' key".format(key) + raise ValueError(msg) + + try: + parsed_value = regex_result[0][idx] + return parsed_value + except IndexError: + self.log.warning("Wrong index, probably " + "wrong name {}".format(name)) + + def _update_representations(self, upd_representations): + """Frames dont have sense for textures, add collected udims instead.""" + udims = [] + for repre in upd_representations: + repre.pop("frameStart", None) + repre.pop("frameEnd", None) + repre.pop("fps", None) + + # ignore unique name from SP, use extension instead + # SP enforces unique name, here different subsets >> unique repres + repre["name"] = repre["ext"].replace('.', '') + + files = repre.get("files", []) + if not isinstance(files, list): + files = [files] + + for file_name in files: + udim = self._get_udim(file_name, + self.input_naming_patterns["textures"], + self.input_naming_groups["textures"], + self.color_space) + udims.append(udim) + + repre["udim"] = udims # must be this way, used for filling path + + return upd_representations diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_resources.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_resources.py new file mode 100644 index 0000000000..1183180833 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_resources.py @@ -0,0 +1,42 @@ +import os +import pyblish.api + + +class ExtractResources(pyblish.api.InstancePlugin): + """ + Extracts files from instance.data["resources"]. + + These files are additional (textures etc.), currently not stored in + representations! + + Expects collected 'resourcesDir'. (list of dicts with 'files' key and + list of source urls) + + Provides filled 'transfers' (list of tuples (source_url, target_url)) + """ + + label = "Extract Resources SP" + hosts = ["standalonepublisher"] + order = pyblish.api.ExtractorOrder + + families = ["workfile"] + + def process(self, instance): + if not instance.data.get("resources"): + self.log.info("No resources") + return + + if not instance.data.get("transfers"): + instance.data["transfers"] = [] + + publish_dir = instance.data["resourcesDir"] + + transfers = [] + for resource in instance.data["resources"]: + for file_url in resource.get("files", []): + file_name = os.path.basename(file_url) + dest_url = os.path.join(publish_dir, file_name) + transfers.append((file_url, dest_url)) + + self.log.info("transfers:: {}".format(transfers)) + instance.data["transfers"].extend(transfers) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py index eb613fa951..059ac9603c 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py @@ -60,7 +60,7 @@ class ExtractTrimVideoAudio(openpype.api.Extractor): ] args = [ - ffmpeg_path, + f"\"{ffmpeg_path}\"", "-ss", str(start / fps), "-i", f"\"{video_file_path}\"", "-t", str(dur / fps) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_workfile_location.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_workfile_location.py new file mode 100644 index 0000000000..18bf0394ae --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_workfile_location.py @@ -0,0 +1,43 @@ +import os +import pyblish.api + + +class ExtractWorkfileUrl(pyblish.api.ContextPlugin): + """ + Modifies 'workfile' field to contain link to published workfile. + + Expects that batch contains only single workfile and matching + (multiple) textures. + """ + + label = "Extract Workfile Url SP" + hosts = ["standalonepublisher"] + order = pyblish.api.ExtractorOrder + + families = ["textures"] + + def process(self, context): + filepath = None + + # first loop for workfile + for instance in context: + if instance.data["family"] == 'workfile': + anatomy = context.data['anatomy'] + template_data = instance.data.get("anatomyData") + rep_name = instance.data.get("representations")[0].get("name") + template_data["representation"] = rep_name + template_data["ext"] = rep_name + 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 filepath: + self.log.info("Texture batch doesn't contain workfile.") + return + + # then apply to all textures + for instance in context: + if instance.data["family"] == 'textures': + instance.data["versionData"]["workfile"] = filepath diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py new file mode 100644 index 0000000000..d592a4a059 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py @@ -0,0 +1,22 @@ +import pyblish.api +import openpype.api + + +class ValidateTextureBatch(pyblish.api.InstancePlugin): + """Validates that some texture files are present.""" + + label = "Validate Texture Presence" + hosts = ["standalonepublisher"] + order = openpype.api.ValidateContentsOrder + families = ["texture_batch_workfile"] + optional = False + + def process(self, instance): + present = False + for instance in instance.context: + if instance.data["family"] == "textures": + self.log.info("Some textures present.") + + return + + assert present, "No textures found in published batch!" diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_has_workfile.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_has_workfile.py new file mode 100644 index 0000000000..7cd540668c --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_has_workfile.py @@ -0,0 +1,20 @@ +import pyblish.api +import openpype.api + + +class ValidateTextureHasWorkfile(pyblish.api.InstancePlugin): + """Validates that textures have appropriate workfile attached. + + Workfile is optional, disable this Validator after Refresh if you are + sure it is not needed. + """ + label = "Validate Texture Has Workfile" + hosts = ["standalonepublisher"] + order = openpype.api.ValidateContentsOrder + families = ["textures"] + optional = True + + def process(self, instance): + wfile = instance.data["versionData"].get("workfile") + + assert wfile, "Textures are missing attached workfile" diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py new file mode 100644 index 0000000000..f210be3631 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py @@ -0,0 +1,50 @@ +import pyblish.api +import openpype.api + + +class ValidateTextureBatchNaming(pyblish.api.InstancePlugin): + """Validates that all instances had properly formatted name.""" + + label = "Validate Texture Batch Naming" + hosts = ["standalonepublisher"] + order = openpype.api.ValidateContentsOrder + families = ["texture_batch_workfile", "textures"] + optional = False + + def process(self, instance): + file_name = instance.data["representations"][0]["files"] + if isinstance(file_name, list): + file_name = file_name[0] + + msg = "Couldnt find asset name in '{}'\n".format(file_name) + \ + "File name doesn't follow configured pattern.\n" + \ + "Please rename the file." + assert "NOT_AVAIL" not in instance.data["asset_build"], msg + + instance.data.pop("asset_build") + + if instance.data["family"] == "textures": + file_name = instance.data["representations"][0]["files"][0] + self._check_proper_collected(instance.data["versionData"], + file_name) + + def _check_proper_collected(self, versionData, file_name): + """ + Loop through collected versionData to check if name parsing was OK. + Args: + versionData: (dict) + + Returns: + raises AssertionException + """ + missing_key_values = [] + for key, value in versionData.items(): + if not value: + missing_key_values.append(key) + + msg = "Collected data {} doesn't contain values for {}".format( + versionData, missing_key_values) + "\n" + \ + "Name of the texture file doesn't match expected pattern.\n" + \ + "Please rename file(s) {}".format(file_name) + + assert not missing_key_values, msg diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_versions.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_versions.py new file mode 100644 index 0000000000..90d0e8e512 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_versions.py @@ -0,0 +1,38 @@ +import pyblish.api +import openpype.api + + +class ValidateTextureBatchVersions(pyblish.api.InstancePlugin): + """Validates that versions match in workfile and textures. + + Workfile is optional, so if you are sure, you can disable this + validator after Refresh. + + Validates that only single version is published at a time. + """ + label = "Validate Texture Batch Versions" + hosts = ["standalonepublisher"] + order = openpype.api.ValidateContentsOrder + families = ["textures"] + optional = False + + def process(self, instance): + wfile = instance.data["versionData"].get("workfile") + + version_str = "v{:03d}".format(instance.data["version"]) + + if not wfile: # no matching workfile, do not check versions + self.log.info("No workfile present for textures") + return + + msg = "Not matching version: texture v{:03d} - workfile {}" + assert version_str in wfile, \ + msg.format( + instance.data["version"], wfile + ) + + present_versions = set() + for instance in instance.context: + present_versions.add(instance.data["version"]) + + assert len(present_versions) == 1, "Too many versions in a batch!" diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py new file mode 100644 index 0000000000..25bb5aea4a --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py @@ -0,0 +1,29 @@ +import pyblish.api +import openpype.api + + +class ValidateTextureBatchWorkfiles(pyblish.api.InstancePlugin): + """Validates that textures workfile has collected resources (optional). + + Collected recourses means secondary workfiles (in most cases). + """ + + label = "Validate Texture Workfile Has Resources" + hosts = ["standalonepublisher"] + order = openpype.api.ValidateContentsOrder + families = ["texture_batch_workfile"] + optional = True + + # from presets + main_workfile_extensions = ['mra'] + + def process(self, instance): + if instance.data["family"] == "workfile": + ext = instance.data["representations"][0]["ext"] + if ext not in self.main_workfile_extensions: + self.log.warning("Only secondary workfile present!") + return + + msg = "No secondary workfiles present for workfile {}".\ + format(instance.data["name"]) + assert instance.data.get("resources"), msg diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py index d8bb03f541..79cc01740a 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py @@ -155,6 +155,7 @@ class CollectWorkfileData(pyblish.api.ContextPlugin): "sceneMarkInState": mark_in_state == "set", "sceneMarkOut": int(mark_out_frame), "sceneMarkOutState": mark_out_state == "set", + "sceneStartFrame": int(lib.execute_george("tv_startframe")), "sceneBgColor": self._get_bg_color() } self.log.debug( diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index 536df2adb0..1df7512588 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -49,6 +49,14 @@ class ExtractSequence(pyblish.api.Extractor): family_lowered = instance.data["family"].lower() 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. + scene_start_frame = instance.context.data["sceneStartFrame"] + difference = scene_start_frame - mark_in + mark_in += difference + mark_out += difference + # Frame start/end may be stored as float frame_start = int(instance.data["frameStart"]) frame_end = int(instance.data["frameEnd"]) @@ -98,7 +106,7 @@ class ExtractSequence(pyblish.api.Extractor): self.log.warning(( "Lowering representation range to {} frames." " Changed frame end {} -> {}" - ).format(output_range + 1, mark_out, new_mark_out)) + ).format(output_range + 1, mark_out, new_output_frame_end)) output_frame_end = new_output_frame_end # ------------------------------------------------------------------- diff --git a/openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py b/openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py new file mode 100644 index 0000000000..a96a8e3d5d --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py @@ -0,0 +1,22 @@ +import pyblish.api + +from avalon.tvpaint import workio +from openpype.api import version_up + + +class IncrementWorkfileVersion(pyblish.api.ContextPlugin): + """Increment current workfile version.""" + + order = pyblish.api.IntegratorOrder + 1 + label = "Increment Workfile Version" + optional = True + hosts = ["tvpaint"] + + def process(self, context): + + assert all(result["success"] for result in context.data["results"]), ( + "Publishing not succesfull so version is not increased.") + + path = context.data["currentFile"] + workio.save_file(version_up(path)) + self.log.info('Incrementing workfile version') diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py b/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py new file mode 100644 index 0000000000..d769d47736 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py @@ -0,0 +1,27 @@ +import pyblish.api +from avalon.tvpaint import lib + + +class RepairStartFrame(pyblish.api.Action): + """Repair start frame.""" + + label = "Repair" + icon = "wrench" + on = "failed" + + def process(self, context, plugin): + lib.execute_george("tv_startframe 0") + + +class ValidateStartFrame(pyblish.api.ContextPlugin): + """Validate start frame being at frame 0.""" + + label = "Validate Start Frame" + order = pyblish.api.ValidatorOrder + hosts = ["tvpaint"] + actions = [RepairStartFrame] + optional = True + + def process(self, context): + start_frame = lib.execute_george("tv_startframe") + assert int(start_frame) == 0, "Start frame has to be frame 0." diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index fb86d06150..fe964d3bab 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1,4 +1,5 @@ import os +import sys import re import copy import json @@ -708,6 +709,10 @@ class ApplicationLaunchContext: ) self.kwargs["creationflags"] = flags + if not sys.stdout: + self.kwargs["stdout"] = subprocess.DEVNULL + self.kwargs["stderr"] = subprocess.DEVNULL + self.prelaunch_hooks = None self.postlaunch_hooks = None @@ -1133,7 +1138,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 @@ -1184,7 +1190,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 @@ -1297,10 +1306,18 @@ def _prepare_last_workfile(data, workdir): ) data["start_last_workfile"] = start_last_workfile + workfile_startup = should_workfile_tool_start( + project_name, app.host_name, task_name + ) + data["workfile_startup"] = workfile_startup + # Store boolean as "0"(False) or "1"(True) data["env"]["AVALON_OPEN_LAST_WORKFILE"] = ( str(int(bool(start_last_workfile))) ) + data["env"]["OPENPYPE_WORKFILE_TOOL_ON_START"] = ( + str(int(bool(workfile_startup))) + ) _sub_msg = "" if start_last_workfile else " not" log.debug( @@ -1339,40 +1356,9 @@ def _prepare_last_workfile(data, workdir): data["last_workfile_path"] = last_workfile_path -def should_start_last_workfile( - project_name, host_name, task_name, default_output=False +def get_option_from_settings( + startup_presets, host_name, task_name, default_output ): - """Define if host should start last version workfile if possible. - - Default output is `False`. Can be overriden with environment variable - `AVALON_OPEN_LAST_WORKFILE`, valid values without case sensitivity are - `"0", "1", "true", "false", "yes", "no"`. - - Args: - project_name (str): Name of project. - host_name (str): Name of host which is launched. In avalon's - application context it's value stored in app definition under - key `"application_dir"`. Is not case sensitive. - task_name (str): Name of task which is used for launching the host. - Task name is not case sensitive. - - Returns: - bool: True if host should start workfile. - - """ - - project_settings = get_project_settings(project_name) - startup_presets = ( - project_settings - ["global"] - ["tools"] - ["Workfiles"] - ["last_workfile_on_startup"] - ) - - if not startup_presets: - return default_output - host_name_lowered = host_name.lower() task_name_lowered = task_name.lower() @@ -1416,6 +1402,82 @@ def should_start_last_workfile( return default_output +def should_start_last_workfile( + project_name, host_name, task_name, default_output=False +): + """Define if host should start last version workfile if possible. + + Default output is `False`. Can be overriden with environment variable + `AVALON_OPEN_LAST_WORKFILE`, valid values without case sensitivity are + `"0", "1", "true", "false", "yes", "no"`. + + Args: + project_name (str): Name of project. + host_name (str): Name of host which is launched. In avalon's + application context it's value stored in app definition under + key `"application_dir"`. Is not case sensitive. + task_name (str): Name of task which is used for launching the host. + Task name is not case sensitive. + + Returns: + bool: True if host should start workfile. + + """ + + project_settings = get_project_settings(project_name) + startup_presets = ( + project_settings + ["global"] + ["tools"] + ["Workfiles"] + ["last_workfile_on_startup"] + ) + + if not startup_presets: + return default_output + + return get_option_from_settings( + startup_presets, host_name, task_name, default_output) + + +def should_workfile_tool_start( + project_name, host_name, task_name, default_output=False +): + """Define if host should start workfile tool at host launch. + + Default output is `False`. Can be overriden with environment variable + `OPENPYPE_WORKFILE_TOOL_ON_START`, valid values without case sensitivity are + `"0", "1", "true", "false", "yes", "no"`. + + Args: + project_name (str): Name of project. + host_name (str): Name of host which is launched. In avalon's + application context it's value stored in app definition under + key `"application_dir"`. Is not case sensitive. + task_name (str): Name of task which is used for launching the host. + Task name is not case sensitive. + + Returns: + bool: True if host should start workfile. + + """ + + project_settings = get_project_settings(project_name) + startup_presets = ( + project_settings + ["global"] + ["tools"] + ["Workfiles"] + ["open_workfile_tool_on_startup"] + ) + + if not startup_presets: + return default_output + + return get_option_from_settings( + startup_presets, host_name, task_name, default_output) + + def compile_list_of_regexes(in_list): """Convert strings in entered list to compiled regex objects.""" regexes = list() diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index a652da7786..6b52e4b387 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" @@ -295,57 +295,70 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): 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"]: @@ -868,10 +881,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 +906,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): @@ -1003,7 +1014,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 +1033,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 +1080,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/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index c71b5106ec..305c71b035 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 @@ -181,6 +181,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_prepare_project.py b/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py index 12d687bbf2..3a96ae3311 100644 --- a/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py +++ b/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py @@ -1,6 +1,8 @@ import json +from avalon.api import AvalonMongoDB from openpype.api import ProjectSettings +from openpype.lib import create_project from openpype.modules.ftrack.lib import ( ServerAction, @@ -21,8 +23,24 @@ class PrepareProjectServer(ServerAction): role_list = ["Pypeclub", "Administrator", "Project Manager"] - # Key to store info about trigerring create folder structure + settings_key = "prepare_project" + item_splitter = {"type": "label", "value": "---"} + _keys_order = ( + "fps", + "frameStart", + "frameEnd", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "resolutionHeight", + "resolutionWidth", + "pixelAspect", + "applications", + "tools_env", + "library_project", + ) def discover(self, session, entities, event): """Show only on project.""" @@ -47,13 +65,7 @@ class PrepareProjectServer(ServerAction): project_entity = entities[0] project_name = project_entity["full_name"] - try: - project_settings = ProjectSettings(project_name) - except ValueError: - return { - "message": "Project is not synchronized yet", - "success": False - } + project_settings = ProjectSettings(project_name) project_anatom_settings = project_settings["project_anatomy"] root_items = self.prepare_root_items(project_anatom_settings) @@ -78,14 +90,13 @@ class PrepareProjectServer(ServerAction): items.extend(ca_items) - # This item will be last (before enumerators) - # - sets value of auto synchronization - auto_sync_name = "avalon_auto_sync" + # This item will be last before enumerators + # Set value of auto synchronization auto_sync_value = project_entity["custom_attributes"].get( CUST_ATTR_AUTO_SYNC, False ) auto_sync_item = { - "name": auto_sync_name, + "name": CUST_ATTR_AUTO_SYNC, "type": "boolean", "value": auto_sync_value, "label": "AutoSync to Avalon" @@ -199,7 +210,18 @@ class PrepareProjectServer(ServerAction): str([key for key in attributes_to_set]) )) - for key, in_data in attributes_to_set.items(): + attribute_keys = set(attributes_to_set.keys()) + keys_order = [] + for key in self._keys_order: + if key in attribute_keys: + keys_order.append(key) + + attribute_keys = attribute_keys - set(keys_order) + for key in sorted(attribute_keys): + keys_order.append(key) + + for key in keys_order: + in_data = attributes_to_set[key] attr = in_data["object"] # initial item definition @@ -225,7 +247,7 @@ class PrepareProjectServer(ServerAction): multiselect_enumerators.append(self.item_splitter) multiselect_enumerators.append({ "type": "label", - "value": in_data["label"] + "value": "

{}

".format(in_data["label"]) }) default = in_data["default"] @@ -286,10 +308,10 @@ class PrepareProjectServer(ServerAction): return items, multiselect_enumerators def launch(self, session, entities, event): - if not event['data'].get('values', {}): + in_data = event["data"].get("values") + if not in_data: return - in_data = event['data']['values'] root_values = {} root_key = "__root__" @@ -337,7 +359,27 @@ class PrepareProjectServer(ServerAction): self.log.debug("Setting Custom Attribute values") - project_name = entities[0]["full_name"] + project_entity = entities[0] + project_name = project_entity["full_name"] + + # Try to find project document + dbcon = AvalonMongoDB() + dbcon.install() + dbcon.Session["AVALON_PROJECT"] = project_name + project_doc = dbcon.find_one({ + "type": "project" + }) + # Create project if is not available + # - creation is required to be able set project anatomy and attributes + if not project_doc: + project_code = project_entity["name"] + self.log.info("Creating project \"{} [{}]\"".format( + project_name, project_code + )) + create_project(project_name, project_code, dbcon=dbcon) + + dbcon.uninstall() + project_settings = ProjectSettings(project_name) project_anatomy_settings = project_settings["project_anatomy"] project_anatomy_settings["roots"] = root_data @@ -352,10 +394,12 @@ class PrepareProjectServer(ServerAction): project_settings.save() - entity = entities[0] - for key, value in custom_attribute_values.items(): - entity["custom_attributes"][key] = value - self.log.debug("- Key \"{}\" set to \"{}\"".format(key, value)) + # Change custom attributes on project + if custom_attribute_values: + for key, value in custom_attribute_values.items(): + project_entity["custom_attributes"][key] = value + self.log.debug("- Key \"{}\" set to \"{}\"".format(key, value)) + session.commit() return True 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_server/action_push_frame_values_to_task.py b/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py index 214f1ecf18..b38e18d089 100644 --- a/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py +++ b/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py @@ -1,3 +1,4 @@ +import sys import json import collections import ftrack_api @@ -90,27 +91,28 @@ class PushHierValuesToNonHier(ServerAction): try: result = self.propagate_values(session, event, entities) - job["status"] = "done" - session.commit() - - return result - - except Exception: - session.rollback() - job["status"] = "failed" - session.commit() + except Exception as exc: msg = "Pushing Custom attribute values to task Failed" + self.log.warning(msg, exc_info=True) + + session.rollback() + + description = "{} (Download traceback)".format(msg) + self.add_traceback_to_job( + job, session, sys.exc_info(), description + ) + return { "success": False, - "message": msg + "message": "Error: {}".format(str(exc)) } - finally: - if job["status"] == "running": - job["status"] = "failed" - session.commit() + job["status"] = "done" + session.commit() + + return result def attrs_configurations(self, session, object_ids, interest_attributes): attrs = session.query(self.cust_attrs_query.format( diff --git a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py index e60045bd50..1dd056adee 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py +++ b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py @@ -1259,7 +1259,7 @@ class SyncToAvalonEvent(BaseEvent): self.process_session, entity, hier_attrs, - self.cust_attr_types_by_id + self.cust_attr_types_by_id.values() ) for key, val in hier_values.items(): output[key] = val diff --git a/openpype/modules/ftrack/event_handlers_user/action_applications.py b/openpype/modules/ftrack/event_handlers_user/action_applications.py index 23c96e1b9f..74d14c2fc4 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_applications.py +++ b/openpype/modules/ftrack/event_handlers_user/action_applications.py @@ -11,29 +11,44 @@ from avalon.api import AvalonMongoDB class AppplicationsAction(BaseAction): - """Application Action class. - - Args: - session (ftrack_api.Session): Session where action will be registered. - label (str): A descriptive string identifing your action. - varaint (str, optional): To group actions together, give them the same - label and specify a unique variant per action. - identifier (str): An unique identifier for app. - description (str): A verbose descriptive text for you action. - icon (str): Url path to icon which will be shown in Ftrack web. - """ + """Applications Action class.""" type = "Application" label = "Application action" - identifier = "pype_app.{}.".format(str(uuid4())) + + identifier = "openpype_app" + _launch_identifier_with_id = None + icon_url = os.environ.get("OPENPYPE_STATICS_SERVER") def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + super(AppplicationsAction, self).__init__(*args, **kwargs) self.application_manager = ApplicationManager() self.dbcon = AvalonMongoDB() + @property + def discover_identifier(self): + if self._discover_identifier is None: + self._discover_identifier = "{}.{}".format( + self.identifier, self.process_identifier() + ) + return self._discover_identifier + + @property + def launch_identifier(self): + if self._launch_identifier is None: + self._launch_identifier = "{}.*".format(self.identifier) + return self._launch_identifier + + @property + def launch_identifier_with_id(self): + if self._launch_identifier_with_id is None: + self._launch_identifier_with_id = "{}.{}".format( + self.identifier, self.process_identifier() + ) + return self._launch_identifier_with_id + def construct_requirements_validations(self): # Override validation as this action does not need them return @@ -56,7 +71,7 @@ class AppplicationsAction(BaseAction): " and data.actionIdentifier={0}" " and source.user.username={1}" ).format( - self.identifier + "*", + self.launch_identifier, self.session.api_user ) self.session.event_hub.subscribe( @@ -136,12 +151,29 @@ class AppplicationsAction(BaseAction): "label": app.group.label, "variant": app.label, "description": None, - "actionIdentifier": self.identifier + app_name, + "actionIdentifier": "{}.{}".format( + self.launch_identifier_with_id, app_name + ), "icon": app_icon }) return items + def _launch(self, event): + event_identifier = event["data"]["actionIdentifier"] + # Check if identifier is same + # - show message that acion may not be triggered on this machine + if event_identifier.startswith(self.launch_identifier_with_id): + return BaseAction._launch(self, event) + + return { + "success": False, + "message": ( + "There are running more OpenPype processes" + " where Application can be launched." + ) + } + def launch(self, session, entities, event): """Callback method for the custom action. @@ -162,7 +194,8 @@ class AppplicationsAction(BaseAction): *event* the unmodified original event """ identifier = event["data"]["actionIdentifier"] - app_name = identifier[len(self.identifier):] + id_identifier_len = len(self.launch_identifier_with_id) + 1 + app_name = identifier[id_identifier_len:] entity = entities[0] diff --git a/openpype/modules/ftrack/event_handlers_user/action_create_project_structure.py b/openpype/modules/ftrack/event_handlers_user/action_create_project_structure.py index d7ac866e42..035a1c60de 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_create_project_structure.py +++ b/openpype/modules/ftrack/event_handlers_user/action_create_project_structure.py @@ -1,5 +1,6 @@ import os import re +import json from openpype.modules.ftrack.lib import BaseAction, statics_icon from openpype.api import Anatomy, get_project_settings @@ -84,6 +85,9 @@ class CreateProjectFolders(BaseAction): } try: + if isinstance(project_folder_structure, str): + project_folder_structure = json.loads(project_folder_structure) + # Get paths based on presets basic_paths = self.get_path_items(project_folder_structure) self.create_folders(basic_paths, project_entity) diff --git a/openpype/modules/ftrack/event_handlers_user/action_multiple_notes.py b/openpype/modules/ftrack/event_handlers_user/action_multiple_notes.py index 8db65fe39b..f5af044de0 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_multiple_notes.py +++ b/openpype/modules/ftrack/event_handlers_user/action_multiple_notes.py @@ -9,16 +9,24 @@ class MultipleNotes(BaseAction): #: Action label. label = 'Multiple Notes' #: Action description. - description = 'Add same note to multiple Asset Versions' + description = 'Add same note to multiple entities' icon = statics_icon("ftrack", "action_icons", "MultipleNotes.svg") def discover(self, session, entities, event): ''' Validation ''' valid = True + + # Check for multiple selection. + if len(entities) < 2: + valid = False + + # Check for valid entities. + valid_entity_types = ['assetversion', 'task'] for entity in entities: - if entity.entity_type.lower() != 'assetversion': + if entity.entity_type.lower() not in valid_entity_types: valid = False break + return valid def interface(self, session, entities, event): @@ -58,7 +66,7 @@ class MultipleNotes(BaseAction): splitter = { 'type': 'label', - 'value': '{}'.format(200*"-") + 'value': '{}'.format(200 * "-") } items = [] diff --git a/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py b/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py index 5298c06371..4b42500e8f 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py +++ b/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py @@ -1,6 +1,8 @@ import json +from avalon.api import AvalonMongoDB from openpype.api import ProjectSettings +from openpype.lib import create_project from openpype.modules.ftrack.lib import ( BaseAction, @@ -23,7 +25,24 @@ class PrepareProjectLocal(BaseAction): settings_key = "prepare_project" # Key to store info about trigerring create folder structure + create_project_structure_key = "create_folder_structure" + create_project_structure_identifier = "create.project.structure" item_splitter = {"type": "label", "value": "---"} + _keys_order = ( + "fps", + "frameStart", + "frameEnd", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "resolutionHeight", + "resolutionWidth", + "pixelAspect", + "applications", + "tools_env", + "library_project", + ) def discover(self, session, entities, event): """Show only on project.""" @@ -48,13 +67,7 @@ class PrepareProjectLocal(BaseAction): project_entity = entities[0] project_name = project_entity["full_name"] - try: - project_settings = ProjectSettings(project_name) - except ValueError: - return { - "message": "Project is not synchronized yet", - "success": False - } + project_settings = ProjectSettings(project_name) project_anatom_settings = project_settings["project_anatomy"] root_items = self.prepare_root_items(project_anatom_settings) @@ -79,14 +92,12 @@ class PrepareProjectLocal(BaseAction): items.extend(ca_items) - # This item will be last (before enumerators) - # - sets value of auto synchronization - auto_sync_name = "avalon_auto_sync" + # Set value of auto synchronization auto_sync_value = project_entity["custom_attributes"].get( CUST_ATTR_AUTO_SYNC, False ) auto_sync_item = { - "name": auto_sync_name, + "name": CUST_ATTR_AUTO_SYNC, "type": "boolean", "value": auto_sync_value, "label": "AutoSync to Avalon" @@ -94,6 +105,27 @@ class PrepareProjectLocal(BaseAction): # Add autosync attribute items.append(auto_sync_item) + # This item will be last before enumerators + # Ask if want to trigger Action Create Folder Structure + create_project_structure_checked = ( + project_settings + ["project_settings"] + ["ftrack"] + ["user_handlers"] + ["prepare_project"] + ["create_project_structure_checked"] + ).value + items.append({ + "type": "label", + "value": "

Want to create basic Folder Structure?

" + }) + items.append({ + "name": self.create_project_structure_key, + "type": "boolean", + "value": create_project_structure_checked, + "label": "Check if Yes" + }) + # Add enumerator items at the end for item in multiselect_enumerators: items.append(item) @@ -200,7 +232,18 @@ class PrepareProjectLocal(BaseAction): str([key for key in attributes_to_set]) )) - for key, in_data in attributes_to_set.items(): + attribute_keys = set(attributes_to_set.keys()) + keys_order = [] + for key in self._keys_order: + if key in attribute_keys: + keys_order.append(key) + + attribute_keys = attribute_keys - set(keys_order) + for key in sorted(attribute_keys): + keys_order.append(key) + + for key in keys_order: + in_data = attributes_to_set[key] attr = in_data["object"] # initial item definition @@ -226,7 +269,7 @@ class PrepareProjectLocal(BaseAction): multiselect_enumerators.append(self.item_splitter) multiselect_enumerators.append({ "type": "label", - "value": in_data["label"] + "value": "

{}

".format(in_data["label"]) }) default = in_data["default"] @@ -287,10 +330,13 @@ class PrepareProjectLocal(BaseAction): return items, multiselect_enumerators def launch(self, session, entities, event): - if not event['data'].get('values', {}): + in_data = event["data"].get("values") + if not in_data: return - in_data = event['data']['values'] + create_project_structure_checked = in_data.pop( + self.create_project_structure_key + ) root_values = {} root_key = "__root__" @@ -338,7 +384,27 @@ class PrepareProjectLocal(BaseAction): self.log.debug("Setting Custom Attribute values") - project_name = entities[0]["full_name"] + project_entity = entities[0] + project_name = project_entity["full_name"] + + # Try to find project document + dbcon = AvalonMongoDB() + dbcon.install() + dbcon.Session["AVALON_PROJECT"] = project_name + project_doc = dbcon.find_one({ + "type": "project" + }) + # Create project if is not available + # - creation is required to be able set project anatomy and attributes + if not project_doc: + project_code = project_entity["name"] + self.log.info("Creating project \"{} [{}]\"".format( + project_name, project_code + )) + create_project(project_name, project_code, dbcon=dbcon) + + dbcon.uninstall() + project_settings = ProjectSettings(project_name) project_anatomy_settings = project_settings["project_anatomy"] project_anatomy_settings["roots"] = root_data @@ -353,11 +419,20 @@ class PrepareProjectLocal(BaseAction): project_settings.save() - entity = entities[0] - for key, value in custom_attribute_values.items(): - entity["custom_attributes"][key] = value - self.log.debug("- Key \"{}\" set to \"{}\"".format(key, value)) + # Change custom attributes on project + if custom_attribute_values: + for key, value in custom_attribute_values.items(): + project_entity["custom_attributes"][key] = value + self.log.debug("- Key \"{}\" set to \"{}\"".format(key, value)) + session.commit() + # Trigger create project structure action + if create_project_structure_checked: + trigger_identifier = "{}.{}".format( + self.create_project_structure_identifier, + self.process_identifier() + ) + self.trigger_action(trigger_identifier, event) return True 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 4ce1a439a3..0000000000 --- a/openpype/modules/ftrack/event_handlers_user/action_where_run_show.py +++ /dev/null @@ -1,82 +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 - - 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_action_handler.py b/openpype/modules/ftrack/lib/ftrack_action_handler.py index 2bff9d8cb3..b24fe5f12a 100644 --- a/openpype/modules/ftrack/lib/ftrack_action_handler.py +++ b/openpype/modules/ftrack/lib/ftrack_action_handler.py @@ -29,6 +29,9 @@ class BaseAction(BaseHandler): icon = None type = 'Action' + _discover_identifier = None + _launch_identifier = None + settings_frack_subkey = "user_handlers" settings_enabled_key = "enabled" @@ -42,6 +45,22 @@ class BaseAction(BaseHandler): super().__init__(session) + @property + def discover_identifier(self): + if self._discover_identifier is None: + self._discover_identifier = "{}.{}".format( + self.identifier, self.process_identifier() + ) + return self._discover_identifier + + @property + def launch_identifier(self): + if self._launch_identifier is None: + self._launch_identifier = "{}.{}".format( + self.identifier, self.process_identifier() + ) + return self._launch_identifier + def register(self): ''' Registers the action, subscribing the the discover and launch topics. @@ -60,7 +79,7 @@ class BaseAction(BaseHandler): ' and data.actionIdentifier={0}' ' and source.user.username={1}' ).format( - self.identifier, + self.launch_identifier, self.session.api_user ) self.session.event_hub.subscribe( @@ -86,7 +105,7 @@ class BaseAction(BaseHandler): 'label': self.label, 'variant': self.variant, 'description': self.description, - 'actionIdentifier': self.identifier, + 'actionIdentifier': self.discover_identifier, 'icon': self.icon, }] } @@ -309,6 +328,78 @@ class BaseAction(BaseHandler): return True +class LocalAction(BaseAction): + """Action that warn user when more Processes with same action are running. + + Action is launched all the time but if id does not match id of current + instanace then message is shown to user. + + Handy for actions where matters if is executed on specific machine. + """ + _full_launch_identifier = None + + @property + def discover_identifier(self): + if self._discover_identifier is None: + self._discover_identifier = "{}.{}".format( + self.identifier, self.process_identifier() + ) + return self._discover_identifier + + @property + def launch_identifier(self): + """Catch all topics with same identifier.""" + if self._launch_identifier is None: + self._launch_identifier = "{}.*".format(self.identifier) + return self._launch_identifier + + @property + def full_launch_identifier(self): + """Catch all topics with same identifier.""" + if self._full_launch_identifier is None: + self._full_launch_identifier = "{}.{}".format( + self.identifier, self.process_identifier() + ) + return self._full_launch_identifier + + def _discover(self, event): + entities = self._translate_event(event) + if not entities: + return + + accepts = self.discover(self.session, entities, event) + if not accepts: + return + + self.log.debug("Discovering action with selection: {0}".format( + event["data"].get("selection", []) + )) + + return { + "items": [{ + "label": self.label, + "variant": self.variant, + "description": self.description, + "actionIdentifier": self.discover_identifier, + "icon": self.icon, + }] + } + + def _launch(self, event): + event_identifier = event["data"]["actionIdentifier"] + # Check if identifier is same + # - show message that acion may not be triggered on this machine + if event_identifier != self.full_launch_identifier: + return { + "success": False, + "message": ( + "There are running more OpenPype processes" + " where this action could be launched." + ) + } + return super(LocalAction, self)._launch(event) + + class ServerAction(BaseAction): """Action class meant to be used on event server. @@ -318,6 +409,14 @@ class ServerAction(BaseAction): settings_frack_subkey = "events" + @property + def discover_identifier(self): + return self.identifier + + @property + def launch_identifier(self): + return self.identifier + def register(self): """Register subcription to Ftrack event hub.""" self.session.event_hub.subscribe( @@ -328,5 +427,5 @@ class ServerAction(BaseAction): launch_subscription = ( "topic=ftrack.action.launch and data.actionIdentifier={0}" - ).format(self.identifier) + ).format(self.launch_identifier) self.session.event_hub.subscribe(launch_subscription, self._launch) diff --git a/openpype/modules/ftrack/lib/ftrack_base_handler.py b/openpype/modules/ftrack/lib/ftrack_base_handler.py index 817841df4a..7b7ebfb099 100644 --- a/openpype/modules/ftrack/lib/ftrack_base_handler.py +++ b/openpype/modules/ftrack/lib/ftrack_base_handler.py @@ -1,4 +1,10 @@ +import os +import tempfile +import json import functools +import uuid +import datetime +import traceback import time from openpype.api import Logger from openpype.settings import get_project_settings @@ -31,6 +37,7 @@ class BaseHandler(object): - a verbose descriptive text for you action - icon in ftrack ''' + _process_id = None # Default priority is 100 priority = 100 # Type is just for logging purpose (e.g.: Action, Event, Application,...) @@ -65,6 +72,13 @@ class BaseHandler(object): self.register = self.register_decorator(self.register) self.launch = self.launch_log(self.launch) + @staticmethod + def process_identifier(): + """Helper property to have """ + if not BaseHandler._process_id: + BaseHandler._process_id = str(uuid.uuid4()) + return BaseHandler._process_id + # Decorator def register_decorator(self, func): @functools.wraps(func) @@ -177,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 @@ -583,3 +604,105 @@ class BaseHandler(object): return "/".join( [ent["name"] for ent in entity["link"]] ) + + @classmethod + def add_traceback_to_job( + cls, job, session, exc_info, + description=None, + component_name=None, + job_status=None + ): + """Add traceback file to a job. + + Args: + job (JobEntity): Entity of job where file should be able to + download (Created or queried with passed session). + session (Session): Ftrack session which was used to query/create + entered job. + exc_info (tuple): Exception info (e.g. from `sys.exc_info()`). + description (str): Change job description to describe what + happened. Job description won't change if not passed. + component_name (str): Name of component and default name of + downloaded file. Class name and current date time are used if + not specified. + job_status (str): Status of job which will be set. By default is + set to 'failed'. + """ + if description: + job_data = { + "description": description + } + job["data"] = json.dumps(job_data) + + if not job_status: + job_status = "failed" + + job["status"] = job_status + + # Create temp file where traceback will be stored + temp_obj = tempfile.NamedTemporaryFile( + mode="w", prefix="openpype_ftrack_", suffix=".txt", delete=False + ) + temp_obj.close() + temp_filepath = temp_obj.name + + # Store traceback to file + result = traceback.format_exception(*exc_info) + with open(temp_filepath, "w") as temp_file: + temp_file.write("".join(result)) + + # Upload file with traceback to ftrack server and add it to job + if not component_name: + component_name = "{}_{}".format( + cls.__name__, + datetime.datetime.now().strftime("%y-%m-%d-%H%M") + ) + cls.add_file_component_to_job( + job, session, temp_filepath, component_name + ) + # Delete temp file + os.remove(temp_filepath) + + @staticmethod + def add_file_component_to_job(job, session, filepath, basename=None): + """Add filepath as downloadable component to job. + + Args: + job (JobEntity): Entity of job where file should be able to + download (Created or queried with passed session). + session (Session): Ftrack session which was used to query/create + entered job. + filepath (str): Path to file which should be added to job. + basename (str): Defines name of file which will be downloaded on + user's side. Must be without extension otherwise extension will + be duplicated in downloaded name. Basename from entered path + used when not entered. + """ + # Make sure session's locations are configured + # - they can be deconfigured e.g. using `rollback` method + session._configure_locations() + + # Query `ftrack.server` location where component will be stored + location = session.query( + "Location where name is \"ftrack.server\"" + ).one() + + # Use filename as basename if not entered (must be without extension) + if basename is None: + basename = os.path.splitext( + os.path.basename(filepath) + )[0] + + component = session.create_component( + filepath, + data={"name": basename}, + location=location + ) + session.create( + "JobComponent", + { + "component_id": component["id"], + "job_id": job["id"] + } + ) + session.commit() diff --git a/openpype/modules/log_viewer/tray/app.py b/openpype/modules/log_viewer/tray/app.py index 9aab37cd20..1e8d6483cd 100644 --- a/openpype/modules/log_viewer/tray/app.py +++ b/openpype/modules/log_viewer/tray/app.py @@ -7,12 +7,13 @@ class LogsWindow(QtWidgets.QWidget): def __init__(self, parent=None): super(LogsWindow, self).__init__(parent) - self.setStyleSheet(style.load_stylesheet()) + self.setWindowTitle("Logs viewer") + self.resize(1400, 800) log_detail = OutputWidget(parent=self) logs_widget = LogsWidget(log_detail, parent=self) - main_layout = QtWidgets.QHBoxLayout() + main_layout = QtWidgets.QHBoxLayout(self) log_splitter = QtWidgets.QSplitter(self) log_splitter.setOrientation(QtCore.Qt.Horizontal) @@ -24,5 +25,4 @@ class LogsWindow(QtWidgets.QWidget): self.logs_widget = logs_widget self.log_detail = log_detail - self.setLayout(main_layout) - self.setWindowTitle("Logs") + self.setStyleSheet(style.load_stylesheet()) diff --git a/openpype/modules/log_viewer/tray/widgets.py b/openpype/modules/log_viewer/tray/widgets.py index b9a8499a4c..5a67780413 100644 --- a/openpype/modules/log_viewer/tray/widgets.py +++ b/openpype/modules/log_viewer/tray/widgets.py @@ -77,12 +77,10 @@ class CustomCombo(QtWidgets.QWidget): toolbutton.setMenu(toolmenu) toolbutton.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup) - layout = QtWidgets.QHBoxLayout() + layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(toolbutton) - self.setLayout(layout) - toolmenu.selection_changed.connect(self.selection_changed) self.toolbutton = toolbutton @@ -141,7 +139,6 @@ class LogsWidget(QtWidgets.QWidget): filter_layout.addWidget(refresh_btn) view = QtWidgets.QTreeView(self) - view.setAllColumnsShowFocus(True) view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) layout = QtWidgets.QVBoxLayout(self) @@ -229,9 +226,9 @@ class OutputWidget(QtWidgets.QWidget): super(OutputWidget, self).__init__(parent=parent) layout = QtWidgets.QVBoxLayout(self) - show_timecode_checkbox = QtWidgets.QCheckBox("Show timestamp") + show_timecode_checkbox = QtWidgets.QCheckBox("Show timestamp", self) - output_text = QtWidgets.QTextEdit() + output_text = QtWidgets.QTextEdit(self) output_text.setReadOnly(True) # output_text.setLineWrapMode(QtWidgets.QTextEdit.FixedPixelWidth) diff --git a/openpype/modules/timers_manager/rest_api.py b/openpype/modules/timers_manager/rest_api.py index 975c1a91f9..ac8d8b7b74 100644 --- a/openpype/modules/timers_manager/rest_api.py +++ b/openpype/modules/timers_manager/rest_api.py @@ -3,6 +3,7 @@ from openpype.api import Logger log = Logger().get_logger("Event processor") + class TimersManagerModuleRestApi: """ REST API endpoint used for calling from hosts when context change @@ -22,6 +23,11 @@ class TimersManagerModuleRestApi: self.prefix + "/start_timer", self.start_timer ) + self.server_manager.add_route( + "POST", + self.prefix + "/stop_timer", + self.stop_timer + ) async def start_timer(self, request): data = await request.json() @@ -38,3 +44,7 @@ class TimersManagerModuleRestApi: self.module.stop_timers() self.module.start_timer(project_name, asset_name, task_name, hierarchy) return Response(status=200) + + async def stop_timer(self, request): + self.module.stop_timers() + return Response(status=200) diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index ef52d51325..91e0a0f3ec 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -44,7 +44,8 @@ class ExtractBurnin(openpype.api.Extractor): "harmony", "fusion", "aftereffects", - "tvpaint" + "tvpaint", + "aftereffects" # "resolve" ] optional = True diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index de54b554e3..bdcd3b8e60 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -44,7 +44,8 @@ class ExtractReview(pyblish.api.InstancePlugin): "standalonepublisher", "fusion", "tvpaint", - "resolve" + "resolve", + "aftereffects" ] # Supported extensions diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index c5ce6d23aa..3504206fe1 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -303,6 +303,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): key_values = {"families": family, "tasks": task_name} profile = filter_profiles(self.template_name_profiles, key_values, logger=self.log) + + template_name = "publish" if profile: template_name = profile["template_name"] @@ -380,7 +382,12 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): test_dest_files = list() for i in [1, 2]: - template_data["frame"] = src_padding_exp % i + template_data["representation"] = repre['ext'] + if not repre.get("udim"): + template_data["frame"] = src_padding_exp % i + else: + template_data["udim"] = src_padding_exp % i + anatomy_filled = anatomy.format(template_data) template_filled = anatomy_filled[template_name]["path"] if repre_context is None: @@ -388,7 +395,10 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): test_dest_files.append( os.path.normpath(template_filled) ) - template_data["frame"] = repre_context["frame"] + if not repre.get("udim"): + template_data["frame"] = repre_context["frame"] + else: + template_data["udim"] = repre_context["udim"] self.log.debug( "test_dest_files: {}".format(str(test_dest_files))) @@ -453,7 +463,9 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): dst_start_frame = dst_padding # Store used frame value to template data - template_data["frame"] = dst_start_frame + if repre.get("frame"): + template_data["frame"] = dst_start_frame + dst = "{0}{1}{2}".format( dst_head, dst_start_frame, @@ -476,6 +488,10 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "Given file name is a full path" ) + template_data["representation"] = repre['ext'] + # Store used frame value to template data + if repre.get("udim"): + template_data["udim"] = repre["udim"][0] src = os.path.join(stagingdir, fname) anatomy_filled = anatomy.format(template_data) template_filled = anatomy_filled[template_name]["path"] @@ -488,6 +504,9 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): repre['published_path'] = dst self.log.debug("__ dst: {}".format(dst)) + if repre.get("udim"): + repre_context["udim"] = repre.get("udim") # store list + repre["publishedFiles"] = published_files for key in self.db_representation_context_keys: @@ -1045,6 +1064,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): ) ) shutil.copy(file_url, new_name) + os.remove(file_url) else: self.log.debug( "Renaming file {} to {}".format( diff --git a/openpype/plugins/publish/validate_editorial_asset_name.py b/openpype/plugins/publish/validate_editorial_asset_name.py index ccea42dc37..eebba61af3 100644 --- a/openpype/plugins/publish/validate_editorial_asset_name.py +++ b/openpype/plugins/publish/validate_editorial_asset_name.py @@ -11,11 +11,12 @@ class ValidateEditorialAssetName(pyblish.api.ContextPlugin): """ order = pyblish.api.ValidatorOrder - label = "Validate Asset Name" + label = "Validate Editorial Asset Name" 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/plugins/publish/validate_instance_in_context.py b/openpype/plugins/publish/validate_instance_in_context.py index 29f002f142..61b4d82027 100644 --- a/openpype/plugins/publish/validate_instance_in_context.py +++ b/openpype/plugins/publish/validate_instance_in_context.py @@ -92,15 +92,16 @@ class RepairSelectInvalidInstances(pyblish.api.Action): context_asset = context.data["assetEntity"]["name"] for instance in instances: - self.set_attribute(instance, context_asset) + if "nuke" in pyblish.api.registered_hosts(): + import openpype.hosts.nuke.api as nuke_api + origin_node = instance[0] + nuke_api.lib.recreate_instance( + origin_node, avalon_data={"asset": context_asset} + ) + else: + self.set_attribute(instance, context_asset) def set_attribute(self, instance, context_asset): - if "nuke" in pyblish.api.registered_hosts(): - import nuke - nuke.toNode( - instance.data.get("name") - )["avalon:asset"].setValue(context_asset) - if "maya" in pyblish.api.registered_hosts(): from maya import cmds cmds.setAttr( diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index ca77171981..dc8d60cb37 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -113,6 +113,10 @@ def _h264_codec_args(ffprobe_data): output.extend(["-codec:v", "h264"]) + bit_rate = ffprobe_data.get("bit_rate") + if bit_rate: + output.extend(["-b:v", bit_rate]) + pix_fmt = ffprobe_data.get("pix_fmt") if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) diff --git a/openpype/settings/defaults/project_anatomy/templates.json b/openpype/settings/defaults/project_anatomy/templates.json index 63477b9d82..53abd35ed5 100644 --- a/openpype/settings/defaults/project_anatomy/templates.json +++ b/openpype/settings/defaults/project_anatomy/templates.json @@ -17,7 +17,7 @@ }, "publish": { "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}/{subset}/{@version}", - "file": "{project[code]}_{asset}_{subset}_{@version}<_{output}><.{@frame}>.{ext}", + "file": "{project[code]}_{asset}_{subset}_{@version}<_{output}><.{@frame}><_{udim}>.{ext}", "path": "{@folder}/{@file}", "thumbnail": "{thumbnail_root}/{project[name]}/{_id}_{thumbnail_type}.{ext}" }, diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 2dba20d63c..efeafbb1ac 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -11,6 +11,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 +45,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 7cf5568662..9fa78ac588 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -136,7 +136,8 @@ "Pypeclub", "Administrator", "Project manager" - ] + ], + "create_project_structure_checked": false }, "clean_hierarchical_attr": { "enabled": true, @@ -297,6 +298,17 @@ "add_ftrack_family": true } ] + }, + { + "hosts": [ + "aftereffects" + ], + "families": [ + "render" + ], + "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 826b5ab465..aab8c2196c 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -1,5 +1,13 @@ { "publish": { + "ValidateEditorialAssetName": { + "enabled": true, + "optional": false + }, + "ValidateVersion": { + "enabled": true, + "optional": false + }, "IntegrateHeroVersion": { "enabled": true, "optional": true, @@ -165,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 @@ -249,6 +235,16 @@ ], "tasks": [], "template": "{family}{Task}" + }, + { + "families": [ + "renderLocal" + ], + "hosts": [ + "aftereffects" + ], + "tasks": [], + "template": "render{Task}{Variant}" } ] }, @@ -260,6 +256,13 @@ "enabled": true } ], + "open_workfile_tool_on_startup": [ + { + "hosts": [], + "tasks": [], + "enabled": false + } + ], "sw_folders": { "compositing": [ "nuke", @@ -277,28 +280,7 @@ } } }, - "project_folder_structure": { - "__project_root__": { - "prod": {}, - "resources": { - "footage": { - "plates": {}, - "offline": {} - }, - "audio": {}, - "art_dept": {} - }, - "editorial": {}, - "assets[ftrack.Library]": { - "characters[ftrack]": {}, - "locations[ftrack]": {} - }, - "shots[ftrack.Sequence]": { - "scripts": {}, - "editorial[ftrack.Folder]": {} - } - } - }, + "project_folder_structure": "{\"__project_root__\": {\"prod\": {}, \"resources\": {\"footage\": {\"plates\": {}, \"offline\": {}}, \"audio\": {}, \"art_dept\": {}}, \"editorial\": {}, \"assets[ftrack.Library]\": {\"characters[ftrack]\": {}, \"locations[ftrack]\": {}}, \"shots[ftrack.Sequence]\": {\"scripts\": {}, \"editorial[ftrack.Folder]\": {}}}}", "sync_server": { "enabled": true, "config": { diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 284a1a0040..592b424fd8 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -7,6 +7,35 @@ "workfile": "ma", "yetiRig": "ma" }, + "maya-dirmap": { + "enabled": true, + "paths": { + "source-path": [ + "foo1", + "foo2" + ], + "destination-path": [ + "bar1", + "bar2" + ] + } + }, + "scriptsmenu": { + "name": "OpenPype Tools", + "definition": [ + { + "type": "action", + "command": "import openpype.hosts.maya.api.commands as op_cmds; op_cmds.edit_shader_definitions()", + "sourcetype": "python", + "title": "Edit shader name definitions", + "tooltip": "Edit shader name definitions used in validation and renaming.", + "tags": [ + "pipeline", + "shader" + ] + } + ] + }, "create": { "CreateLook": { "enabled": true, @@ -148,12 +177,14 @@ }, "ValidateModelName": { "enabled": false, + "database": true, "material_file": { "windows": "", "darwin": "", "linux": "" }, - "regex": "(.*)_(\\\\d)*_(.*)_(GEO)" + "regex": "(.*)_(\\d)*_(?P.*)_(GEO)", + "top_level_regex": ".*_GRP" }, "ValidateTransformNamingSuffix": { "enabled": true, diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 71bf46d5b3..136f1d6b42 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -10,11 +10,22 @@ }, "create": { "CreateWriteRender": { - "fpath_template": "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}" + "fpath_template": "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}", + "defaults": [ + "Main", + "Mask" + ] }, "CreateWritePrerender": { "fpath_template": "{work}/prerenders/nuke/{subset}/{subset}.{frame}.{ext}", - "use_range_limit": true + "use_range_limit": true, + "defaults": [ + "Key01", + "Bg01", + "Fg01", + "Branch01", + "Part01" + ] } }, "publish": { diff --git a/openpype/settings/defaults/project_settings/standalonepublisher.json b/openpype/settings/defaults/project_settings/standalonepublisher.json index f08212934d..50c1e34366 100644 --- a/openpype/settings/defaults/project_settings/standalonepublisher.json +++ b/openpype/settings/defaults/project_settings/standalonepublisher.json @@ -123,6 +123,16 @@ ], "help": "Process multiple Mov files and publish them for layout and comp." }, + "create_texture_batch": { + "name": "texture_batch", + "label": "Texture Batch", + "family": "texture_batch", + "icon": "image", + "defaults": [ + "Main" + ], + "help": "Texture files with UDIM together with worfile" + }, "__dynamic_keys_labels__": { "create_workfile": "Workfile", "create_model": "Model", @@ -134,10 +144,65 @@ "create_image": "Image", "create_matchmove": "Matchmove", "create_render": "Render", - "create_mov_batch": "Batch Mov" + "create_mov_batch": "Batch Mov", + "create_texture_batch": "Batch Texture" } }, "publish": { + "CollectTextures": { + "enabled": true, + "active": true, + "main_workfile_extensions": [ + "mra" + ], + "other_workfile_extensions": [ + "spp", + "psd" + ], + "texture_extensions": [ + "exr", + "dpx", + "jpg", + "jpeg", + "png", + "tiff", + "tga", + "gif", + "svg" + ], + "workfile_families": [], + "texture_families": [], + "color_space": [ + "linsRGB", + "raw", + "acesg" + ], + "input_naming_patterns": { + "workfile": [ + "^([^.]+)(_[^_.]*)?_v([0-9]{3,}).+" + ], + "textures": [ + "^([^_.]+)_([^_.]+)_v([0-9]{3,})_([^_.]+)_({color_space})_(1[0-9]{3}).+" + ] + }, + "input_naming_groups": { + "workfile": [ + "asset", + "filler", + "version" + ], + "textures": [ + "asset", + "shader", + "version", + "channel", + "color_space", + "udim" + ] + }, + "workfile_subset_template": "textures{Subset}Workfile", + "texture_subset_template": "textures{Subset}_{Shader}_{Channel}" + }, "ValidateSceneSettings": { "enabled": true, "optional": true, @@ -189,7 +254,7 @@ }, "shot_add_tasks": {} }, - "shot_add_tasks": { + "CollectInstances": { "custom_start_frame": 0, "timeline_frame_start": 900000, "timeline_frame_offset": 0, diff --git a/openpype/settings/defaults/project_settings/tvpaint.json b/openpype/settings/defaults/project_settings/tvpaint.json index 763802a73f..47f486aa98 100644 --- a/openpype/settings/defaults/project_settings/tvpaint.json +++ b/openpype/settings/defaults/project_settings/tvpaint.json @@ -18,6 +18,11 @@ "optional": true, "active": true }, + "ValidateStartFrame": { + "enabled": false, + "optional": true, + "active": true + }, "ValidateAssetName": { "enabled": true, "optional": true, 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/entities/anatomy_entities.py b/openpype/settings/entities/anatomy_entities.py index d048ffabba..489e1f8294 100644 --- a/openpype/settings/entities/anatomy_entities.py +++ b/openpype/settings/entities/anatomy_entities.py @@ -1,5 +1,6 @@ from .dict_immutable_keys_entity import DictImmutableKeysEntity from .lib import OverrideState +from .exceptions import EntitySchemaError class AnatomyEntity(DictImmutableKeysEntity): @@ -23,3 +24,25 @@ class AnatomyEntity(DictImmutableKeysEntity): if not child_obj.has_project_override: child_obj.add_to_project_override() return super(AnatomyEntity, self).on_child_change(child_obj) + + def schema_validations(self): + non_group_children = [] + for key, child_obj in self.non_gui_children.items(): + if not child_obj.is_group: + non_group_children.append(key) + + if non_group_children: + _non_group_children = [ + "project_anatomy/{}".format(key) + for key in non_group_children + ] + reason = ( + "Anatomy must have all children as groups." + " Set 'is_group' to `true` on > {}" + ).format(", ".join([ + '"{}"'.format(item) + for item in _non_group_children + ])) + raise EntitySchemaError(self, reason) + + return super(AnatomyEntity, self).schema_validations() diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index 96065b670e..b7c64f173f 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -141,9 +141,17 @@ 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 + # GUI attributes + self.enum_is_horizontal = self.schema_data.get( + "enum_is_horizontal", False + ) + # `enum_on_right` can be used only if + self.enum_on_right = self.schema_data.get("enum_on_right", False) + self.highlight_content = self.schema_data.get( "highlight_content", False ) @@ -185,13 +193,13 @@ class DictConditionalEntity(ItemEntity): children_def_keys = [] for children_def in self.enum_children: if not isinstance(children_def, dict): - raise EntitySchemaError(( + raise EntitySchemaError(self, ( "Children definition under key 'enum_children' must" " be a dictionary." )) if "key" not in children_def: - raise EntitySchemaError(( + raise EntitySchemaError(self, ( "Children definition under key 'enum_children' miss" " 'key' definition." )) @@ -270,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 = { @@ -286,7 +301,8 @@ class DictConditionalEntity(ItemEntity): "multiselection": False, "enum_items": enum_items, "key": enum_key, - "label": self.enum_label or enum_key + "label": self.enum_label, + "default": default_key } enum_entity = self.create_schema_object(enum_schema, self) diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index d306eca7ef..361ad38dc5 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -1,3 +1,4 @@ +import copy from .input_entities import InputEntity from .exceptions import EntitySchemaError from .lib import ( @@ -72,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, ) @@ -118,30 +139,43 @@ class HostsEnumEntity(BaseEnumEntity): implementation instead of application name. """ schema_types = ["hosts-enum"] + all_host_names = [ + "aftereffects", + "blender", + "celaction", + "fusion", + "harmony", + "hiero", + "houdini", + "maya", + "nuke", + "photoshop", + "resolve", + "tvpaint", + "unreal", + "standalonepublisher" + ] def _item_initalization(self): self.multiselection = self.schema_data.get("multiselection", True) - self.use_empty_value = self.schema_data.get( - "use_empty_value", not self.multiselection - ) + use_empty_value = False + if not self.multiselection: + use_empty_value = self.schema_data.get( + "use_empty_value", use_empty_value + ) + self.use_empty_value = use_empty_value + + hosts_filter = self.schema_data.get("hosts_filter") or [] + self.hosts_filter = hosts_filter + custom_labels = self.schema_data.get("custom_labels") or {} - host_names = [ - "aftereffects", - "blender", - "celaction", - "fusion", - "harmony", - "hiero", - "houdini", - "maya", - "nuke", - "photoshop", - "resolve", - "tvpaint", - "unreal", - "standalonepublisher" - ] + host_names = copy.deepcopy(self.all_host_names) + if hosts_filter: + for host_name in tuple(host_names): + if host_name not in hosts_filter: + host_names.remove(host_name) + if self.use_empty_value: host_names.insert(0, "") # Add default label for empty value if not available @@ -173,6 +207,44 @@ class HostsEnumEntity(BaseEnumEntity): # GUI attribute self.placeholder = self.schema_data.get("placeholder") + def schema_validations(self): + if self.hosts_filter: + enum_len = len(self.enum_items) + if ( + enum_len == 0 + or (enum_len == 1 and self.use_empty_value) + ): + joined_filters = ", ".join([ + '"{}"'.format(item) + for item in self.hosts_filter + ]) + reason = ( + "All host names were removed after applying" + " host filters. {}" + ).format(joined_filters) + raise EntitySchemaError(self, reason) + + invalid_filters = set() + for item in self.hosts_filter: + if item not in self.all_host_names: + invalid_filters.add(item) + + if invalid_filters: + joined_filters = ", ".join([ + '"{}"'.format(item) + for item in self.hosts_filter + ]) + expected_hosts = ", ".join([ + '"{}"'.format(item) + for item in self.all_host_names + ]) + self.log.warning(( + "Host filters containt invalid host names:" + " \"{}\" Expected values are {}" + ).format(joined_filters, expected_hosts)) + + super(HostsEnumEntity, self).schema_validations() + class AppsEnumEntity(BaseEnumEntity): schema_types = ["apps-enum"] diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 2abb7a2253..6952529963 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -1,5 +1,6 @@ import re import copy +import json from abc import abstractmethod from .base_entity import ItemEntity @@ -440,6 +441,7 @@ class RawJsonEntity(InputEntity): def _item_initalization(self): # Schema must define if valid value is dict or list + store_as_string = self.schema_data.get("store_as_string", False) is_list = self.schema_data.get("is_list", False) if is_list: valid_value_types = (list, ) @@ -448,6 +450,8 @@ class RawJsonEntity(InputEntity): valid_value_types = (dict, ) value_on_not_set = {} + self.store_as_string = store_as_string + self._is_list = is_list self.valid_value_types = valid_value_types self.value_on_not_set = value_on_not_set @@ -491,6 +495,23 @@ class RawJsonEntity(InputEntity): result = self.metadata != self._metadata_for_current_state() return result + def schema_validations(self): + if self.store_as_string and self.is_env_group: + reason = ( + "RawJson entity can't store environment group metadata" + " as string." + ) + raise EntitySchemaError(self, reason) + super(RawJsonEntity, self).schema_validations() + + def _convert_to_valid_type(self, value): + if isinstance(value, STRING_TYPE): + try: + return json.loads(value) + except Exception: + pass + return super(RawJsonEntity, self)._convert_to_valid_type(value) + def _metadata_for_current_state(self): if ( self._override_state is OverrideState.PROJECT @@ -510,6 +531,9 @@ class RawJsonEntity(InputEntity): value = super(RawJsonEntity, self)._settings_value() if self.is_env_group and isinstance(value, dict): value.update(self.metadata) + + if self.store_as_string: + return json.dumps(value) return value def _prepare_value(self, value): 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..b07441251a 100644 --- a/openpype/settings/entities/list_entity.py +++ b/openpype/settings/entities/list_entity.py @@ -141,7 +141,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 +163,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 +186,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/schemas/README.md b/openpype/settings/entities/schemas/README.md index 3c360b892f..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 @@ -204,6 +205,8 @@ - it is possible to add darker background with `"highlight_content"` (Default: `False`) - darker background has limits of maximum applies after 3-4 nested highlighted items there is not difference in the color - output is dictionary `{the "key": children values}` +- for UI porposes was added `enum_is_horizontal` which will make combobox appear next to children inputs instead of on top of them (Default: `False`) + - this has extended ability of `enum_on_right` which will move combobox to right side next to children widgets (Default: `False`) ``` # Example { @@ -337,6 +340,11 @@ How output of the schema could look like on save: - schema also defines valid value type - by default it is dictionary - to be able use list it is required to define `is_list` to `true` +- output can be stored as string + - this is to allow any keys in dictionary + - set key `store_as_string` to `true` + - code using that setting must expected that value is string and use json module to convert it to python types + ``` { "type": "raw-json", @@ -352,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 ``` { @@ -364,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"} ] } ``` @@ -374,6 +385,9 @@ How output of the schema could look like on save: - multiselection can be allowed with setting key `"multiselection"` to `True` (Default: `False`) - it is possible to add empty value (represented with empty string) with setting `"use_empty_value"` to `True` (Default: `False`) - it is possible to set `"custom_labels"` for host names where key `""` is empty value (Default: `{}`) +- to filter host names it is required to define `"hosts_filter"` which is list of host names that will be available + - do not pass empty string if `use_empty_value` is enabled + - ignoring host names would be more dangerous in some cases ``` { "key": "host", @@ -384,7 +398,10 @@ How output of the schema could look like on save: "custom_labels": { "": "N/A", "nuke": "Nuke" - } + }, + "hosts_filter": [ + "nuke" + ] } ``` @@ -404,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 ``` @@ -429,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 @@ -572,6 +650,15 @@ How output of the schema could look like on save: } ``` +## Anatomy +Anatomy represents data stored on project document. + +### anatomy +- entity works similarly to `dict` +- anatomy has always all keys overriden with overrides + - overrides are not applied as all anatomy data must be available from project document + - all children must be groups + ## Proxy wrappers - should wraps multiple inputs only visually - these does not have `"key"` key and do not allow to have `"is_file"` or `"is_group"` modifiers enabled 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..53c6bf48c0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -52,11 +52,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 +213,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_ftrack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json index a94ebc8888..1cc08b96f8 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json @@ -441,6 +441,18 @@ "key": "role_list", "label": "Roles", "object_type": "text" + }, + { + "type": "separator" + }, + { + "type": "label", + "label": "Check \"Create project structure\" by default" + }, + { + "type": "boolean", + "key": "create_project_structure_checked", + "label": "Checked" } ] }, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_global.json b/openpype/settings/entities/schemas/projects_schema/schema_project_global.json index 6e5cf0671c..a8bce47592 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_global.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_global.json @@ -17,7 +17,8 @@ "type": "raw-json", "label": "Project Folder Structure", "key": "project_folder_structure", - "use_label_wrap": true + "use_label_wrap": true, + "store_as_string": true }, { "type": "schema", 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 0a59cab510..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,43 @@ "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" + }, { "type": "schema", "name": "schema_maya_create" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json index 01a954f283..e0b21f4037 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json @@ -63,6 +63,14 @@ "type": "text", "key": "fpath_template", "label": "Path template" + }, + { + "type": "list", + "key": "defaults", + "label": "Subset name defaults", + "object_type": { + "type": "text" + } } ] }, @@ -82,6 +90,14 @@ "type": "boolean", "key": "use_range_limit", "label": "Use Frame range limit by default" + }, + { + "type": "list", + "key": "defaults", + "label": "Subset name defaults", + "object_type": { + "type": "text" + } } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_standalonepublisher.json b/openpype/settings/entities/schemas/projects_schema/schema_project_standalonepublisher.json index c627012531..37fcaac69f 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_standalonepublisher.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_standalonepublisher.json @@ -56,6 +56,119 @@ "key": "publish", "label": "Publish plugins", "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CollectTextures", + "label": "Collect Textures", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "list", + "key": "main_workfile_extensions", + "object_type": "text", + "label": "Main workfile extensions" + }, + { + "key": "other_workfile_extensions", + "label": "Support workfile extensions", + "type": "list", + "object_type": "text" + }, + { + "type": "list", + "key": "texture_extensions", + "object_type": "text", + "label": "Texture extensions" + }, + { + "type": "list", + "key": "workfile_families", + "object_type": "text", + "label": "Additional families for workfile" + }, + { + "type": "list", + "key": "texture_families", + "object_type": "text", + "label": "Additional families for textures" + }, + { + "type": "list", + "key": "color_space", + "object_type": "text", + "label": "Color spaces" + }, + { + "type": "dict", + "collapsible": false, + "key": "input_naming_patterns", + "label": "Regex patterns for naming conventions", + "children": [ + { + "type": "label", + "label": "Add regex groups matching expected name" + }, + { + "type": "list", + "object_type": "text", + "key": "workfile", + "label": "Workfile naming pattern" + }, + { + "type": "list", + "object_type": "text", + "key": "textures", + "label": "Textures naming pattern" + } + ] + }, + { + "type": "dict", + "collapsible": false, + "key": "input_naming_groups", + "label": "Group order for regex patterns", + "children": [ + { + "type": "label", + "label": "Add names of matched groups in correct order. Available values: ('filler', 'asset', 'shader', 'version', 'channel', 'color_space', 'udim')" + }, + { + "type": "list", + "object_type": "text", + "key": "workfile", + "label": "Workfile group positions" + }, + { + "type": "list", + "object_type": "text", + "key": "textures", + "label": "Textures group positions" + } + ] + }, + { + "type": "text", + "key": "workfile_subset_template", + "label": "Subset name template for workfile" + }, + { + "type": "text", + "key": "texture_subset_template", + "label": "Subset name template for textures" + } + ] + }, { "type": "dict", "collapsible": true, @@ -214,7 +327,7 @@ { "type": "dict", "collapsible": true, - "key": "shot_add_tasks", + "key": "CollectInstances", "label": "Collect Clip Instances", "is_group": true, "children": [ diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json index 67aa4b0a06..368141813f 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json @@ -52,6 +52,17 @@ } ] }, + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateStartFrame", + "label": "Validate Scene Start Frame", + "docstring": "Validate first frame of scene is set to '0'." + } + ] + }, { "type": "schema_template", "name": "template_publish_plugin", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json index 2b2eab8868..3c589f9492 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -3,6 +3,7 @@ "key": "imageio", "label": "Color Management and Output Formats", "is_file": true, + "is_group": true, "children": [ { "key": "hiero", @@ -14,7 +15,6 @@ "type": "dict", "label": "Workfile", "collapsible": false, - "is_group": true, "children": [ { "type": "form", @@ -89,7 +89,6 @@ "type": "dict", "label": "Colorspace on Inputs by regex detection", "collapsible": true, - "is_group": true, "children": [ { "type": "list", @@ -124,7 +123,6 @@ "type": "dict", "label": "Viewer", "collapsible": false, - "is_group": true, "children": [ { "type": "text", @@ -138,7 +136,6 @@ "type": "dict", "label": "Workfile", "collapsible": false, - "is_group": true, "children": [ { "type": "form", @@ -236,7 +233,6 @@ "type": "dict", "label": "Nodes", "collapsible": true, - "is_group": true, "children": [ { "key": "requiredNodes", @@ -339,7 +335,6 @@ "type": "dict", "label": "Colorspace on Inputs by regex detection", "collapsible": true, - "is_group": true, "children": [ { "type": "list", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index 4715db4888..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 @@ -4,6 +4,46 @@ "key": "publish", "label": "Publish plugins", "children": [ + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "ValidateEditorialAssetName", + "label": "Validate Editorial Asset Name", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + } + ] + }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "ValidateVersion", + "label": "Validate Version", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + } + ] + }, { "type": "dict", "collapsible": true, @@ -516,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_global_tools.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json index 8c92a45a56..9e39eeb39e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json @@ -78,7 +78,57 @@ "type": "hosts-enum", "key": "hosts", "label": "Hosts", - "multiselection": true + "multiselection": true, + "hosts_filter": [ + "aftereffects", + "blender", + "celaction", + "fusion", + "harmony", + "hiero", + "houdini", + "maya", + "nuke", + "photoshop", + "resolve", + "tvpaint", + "unreal" + ] + }, + { + "key": "tasks", + "label": "Tasks", + "type": "list", + "object_type": "text" + }, + { + "type": "splitter" + }, + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + } + }, + { + "type": "list", + "key": "open_workfile_tool_on_startup", + "label": "Open workfile tool on launch", + "is_group": true, + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "hosts-enum", + "key": "hosts", + "label": "Hosts", + "multiselection": true, + "hosts_filter": [ + "nuke" + ] }, { "key": "tasks", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 5ca7059ee5..89cd30aed0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -147,9 +147,14 @@ "key": "enabled", "label": "Enabled" }, + { + "type": "boolean", + "key": "database", + "label": "Use database shader name definitions" + }, { "type": "label", - "label": "Path to material file defining list of material names to check. This is material name per line simple text file.
It will be checked against named group shader in your Validation regex.

For example:
^.*(?P=<shader>.+)_GEO

" + "label": "Path to material file defining list of material names to check. This is material name per line simple text file.
It will be checked against named group shader in your Validation regex.

For example:
^.*(?P=<shader>.+)_GEO

This is used instead of database definitions if they are disabled." }, { "type": "path", @@ -162,6 +167,15 @@ "type": "text", "key": "regex", "label": "Validation regex" + }, + { + "type": "label", + "label": "Regex for validating name of top level group name.
You can use named capturing groups:
(?P<asset>.*) for Asset name
(?P<subset>.*) for Subset
(?P<project>.*) for project

For example to check for asset in name so *_some_asset_name_GRP is valid, use:
.*?_(?P<asset>.*)_GEO" + }, + { + "type": "text", + "key": "top_level_regex", + "label": "Top level group name regex" } ] }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_scriptsmenu.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_scriptsmenu.json new file mode 100644 index 0000000000..e841d6ba77 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_scriptsmenu.json @@ -0,0 +1,22 @@ +{ + "type": "dict", + "collapsible": true, + "key": "scriptsmenu", + "label": "Scripts Menu Definition", + "children": [ + { + "type": "text", + "key": "name", + "label": "Menu Name" + }, + { + "type": "splitter" + }, + { + "type": "raw-json", + "key": "definition", + "label": "Menu definition", + "is_list": true + } + ] +} \ No newline at end of file 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 c3287d7452..f633d5cb1a 100644 --- a/openpype/settings/entities/schemas/system_schema/example_schema.json +++ b/openpype/settings/entities/schemas/system_schema/example_schema.json @@ -9,6 +9,31 @@ "label": "Color input", "type": "color" }, + { + "type": "dict-conditional", + "key": "overriden_value", + "label": "Overriden value", + "enum_key": "overriden", + "enum_is_horizontal": true, + "enum_children": [ + { + "key": "overriden", + "label": "Override value", + "children": [ + { + "type": "number", + "key": "value", + "label": "value" + } + ] + }, + { + "key": "inherit", + "label": "Inherit value", + "children": [] + } + ] + }, { "type": "dict-conditional", "use_label_wrap": true, @@ -57,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/lib.py b/openpype/settings/lib.py index 4a3e66de33..5c2c0dcd94 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -315,14 +315,28 @@ class DuplicatedEnvGroups(Exception): super(DuplicatedEnvGroups, self).__init__(msg) +def load_openpype_default_settings(): + """Load openpype default settings.""" + return load_jsons_from_dir(DEFAULTS_DIR) + + def reset_default_settings(): + """Reset cache of default settings. Can't be used now.""" global _DEFAULT_SETTINGS _DEFAULT_SETTINGS = None def get_default_settings(): + """Get default settings. + + Todo: + Cache loaded defaults. + + Returns: + dict: Loaded default settings. + """ # TODO add cacher - return load_jsons_from_dir(DEFAULTS_DIR) + return load_openpype_default_settings() # global _DEFAULT_SETTINGS # if _DEFAULT_SETTINGS is None: # _DEFAULT_SETTINGS = load_jsons_from_dir(DEFAULTS_DIR) @@ -868,6 +882,25 @@ def get_environments(): return find_environments(get_system_settings(False)) +def get_general_environments(): + """Get general environments. + + Function is implemented to be able load general environments without using + `get_default_settings`. + """ + # Use only openpype defaults. + # - prevent to use `get_system_settings` where `get_default_settings` + # is used + default_values = load_openpype_default_settings() + studio_overrides = get_studio_system_settings_overrides() + result = apply_overrides(default_values, studio_overrides) + environments = result["general"]["environment"] + + clear_metadata_from_settings(environments) + + return environments + + def clear_metadata_from_settings(values): """Remove all metadata keys from loaded settings.""" if isinstance(values, dict): diff --git a/openpype/style/style.css b/openpype/style/style.css index c57b9a8da6..b955bdc2a6 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -35,6 +35,10 @@ QWidget:disabled { color: {color:font-disabled}; } +QLabel { + background: transparent; +} + /* Inputs */ QAbstractSpinBox, QLineEdit, QPlainTextEdit, QTextEdit { border: 1px solid {color:border}; @@ -97,7 +101,7 @@ QToolButton:disabled { background: {color:bg-buttons-disabled}; } -QToolButton[popupMode="1"] { +QToolButton[popupMode="1"], QToolButton[popupMode="MenuButtonPopup"] { /* make way for the popup button */ padding-right: 20px; border: 1px solid {color:bg-buttons}; @@ -340,6 +344,11 @@ QAbstractItemView { selection-background-color: transparent; } +QAbstractItemView::item { + /* `border: none` hide outline of selected item. */ + border: none; +} + QAbstractItemView:disabled{ background: {color:bg-view-disabled}; alternate-background-color: {color:bg-view-alternate-disabled}; 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/categories.py b/openpype/tools/settings/settings/categories.py index 0aacd590db..8be3eddfa8 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -294,6 +294,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget): msg = "

".join(warnings) dialog = QtWidgets.QMessageBox(self) + dialog.setWindowTitle("Save warnings") dialog.setText(msg) dialog.setIcon(QtWidgets.QMessageBox.Warning) dialog.exec_() @@ -303,6 +304,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget): except Exception as exc: formatted_traceback = traceback.format_exception(*sys.exc_info()) dialog = QtWidgets.QMessageBox(self) + dialog.setWindowTitle("Unexpected error") msg = "Unexpected error happened!\n\nError: {}".format(str(exc)) dialog.setText(msg) dialog.setDetailedText("\n".join(formatted_traceback)) @@ -392,6 +394,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget): except Exception as exc: formatted_traceback = traceback.format_exception(*sys.exc_info()) dialog = QtWidgets.QMessageBox(self) + dialog.setWindowTitle("Unexpected error") msg = "Unexpected error happened!\n\nError: {}".format(str(exc)) dialog.setText(msg) dialog.setDetailedText("\n".join(formatted_traceback)) diff --git a/openpype/tools/settings/settings/dict_conditional.py b/openpype/tools/settings/settings/dict_conditional.py index da2f53497e..31a4fa9fab 100644 --- a/openpype/tools/settings/settings/dict_conditional.py +++ b/openpype/tools/settings/settings/dict_conditional.py @@ -24,6 +24,7 @@ class DictConditionalWidget(BaseWidget): self.body_widget = None self.content_widget = None self.content_layout = None + self.enum_layout = None label = None if self.entity.is_dynamic_item: @@ -40,8 +41,36 @@ class DictConditionalWidget(BaseWidget): self._enum_key_by_wrapper_id = {} self._added_wrapper_ids = set() - self.content_layout.setColumnStretch(0, 0) - self.content_layout.setColumnStretch(1, 1) + enum_layout = QtWidgets.QGridLayout() + enum_layout.setContentsMargins(0, 0, 0, 0) + enum_layout.setColumnStretch(0, 0) + enum_layout.setColumnStretch(1, 1) + + all_children_layout = QtWidgets.QVBoxLayout() + all_children_layout.setContentsMargins(0, 0, 0, 0) + + if self.entity.enum_is_horizontal: + if self.entity.enum_on_right: + self.content_layout.addLayout(all_children_layout, 0, 0) + self.content_layout.addLayout(enum_layout, 0, 1) + # Stretch combobox to minimum and expand value + self.content_layout.setColumnStretch(0, 1) + self.content_layout.setColumnStretch(1, 0) + else: + self.content_layout.addLayout(enum_layout, 0, 0) + self.content_layout.addLayout(all_children_layout, 0, 1) + # Stretch combobox to minimum and expand value + self.content_layout.setColumnStretch(0, 0) + self.content_layout.setColumnStretch(1, 1) + + else: + # Expand content + self.content_layout.setColumnStretch(0, 1) + self.content_layout.addLayout(enum_layout, 0, 0) + self.content_layout.addLayout(all_children_layout, 1, 0) + + self.enum_layout = enum_layout + self.all_children_layout = all_children_layout # Add enum entity to layout mapping enum_entity = self.entity.enum_entity @@ -58,6 +87,8 @@ class DictConditionalWidget(BaseWidget): content_layout.setContentsMargins(0, 0, 0, 0) content_layout.setSpacing(5) + all_children_layout.addWidget(content_widget) + self._content_by_enum_value[enum_key] = { "widget": content_widget, "layout": content_layout @@ -80,9 +111,6 @@ class DictConditionalWidget(BaseWidget): for item_key, children in self.entity.children.items(): content_widget = self._content_by_enum_value[item_key]["widget"] - row = self.content_layout.rowCount() - self.content_layout.addWidget(content_widget, row, 0, 1, 2) - for child_obj in children: self.input_fields.append( self.create_ui_for_entity( @@ -191,12 +219,25 @@ class DictConditionalWidget(BaseWidget): else: map_id = widget.entity.id - content_widget = self.content_widget - content_layout = self.content_layout - if map_id != self.entity.enum_entity.id: - enum_value = self._enum_key_by_wrapper_id[map_id] - content_widget = self._content_by_enum_value[enum_value]["widget"] - content_layout = self._content_by_enum_value[enum_value]["layout"] + is_enum_item = map_id == self.entity.enum_entity.id + if is_enum_item: + content_widget = self.content_widget + content_layout = self.enum_layout + + if not label: + content_layout.addWidget(widget, 0, 0, 1, 2) + return + + label_widget = GridLabelWidget(label, widget) + label_widget.input_field = widget + widget.label_widget = label_widget + content_layout.addWidget(label_widget, 0, 0, 1, 1) + content_layout.addWidget(widget, 0, 1, 1, 1) + return + + enum_value = self._enum_key_by_wrapper_id[map_id] + content_widget = self._content_by_enum_value[enum_value]["widget"] + content_layout = self._content_by_enum_value[enum_value]["layout"] wrapper = self._parent_widget_by_entity_id[map_id] if wrapper is not content_widget: diff --git a/openpype/tools/settings/settings/dict_mutable_widget.py b/openpype/tools/settings/settings/dict_mutable_widget.py index df6525e86a..3526dc60b5 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 @@ -415,6 +517,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 +544,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 +567,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 +792,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 +858,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 +946,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) diff --git a/openpype/tools/settings/settings/list_item_widget.py b/openpype/tools/settings/settings/list_item_widget.py index 82ca541132..c9df5caf01 100644 --- a/openpype/tools/settings/settings/list_item_widget.py +++ b/openpype/tools/settings/settings/list_item_widget.py @@ -117,6 +117,9 @@ class ListItem(QtWidgets.QWidget): self.spacer_widget = spacer_widget + self._row = -1 + self._is_last = False + @property def category_widget(self): return self.entity_widget.category_widget @@ -136,28 +139,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 +183,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 +206,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 +259,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) @@ -262,39 +277,60 @@ class ListWidget(InputWidget): 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 +343,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 +366,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/window.py b/openpype/tools/settings/settings/window.py index a60a2a1d88..4e88301349 100644 --- a/openpype/tools/settings/settings/window.py +++ b/openpype/tools/settings/settings/window.py @@ -94,7 +94,8 @@ class MainWidget(QtWidgets.QWidget): super(MainWidget, self).showEvent(event) if self._reset_on_show: self._reset_on_show = False - self.reset() + # Trigger reset with 100ms delay + QtCore.QTimer.singleShot(100, self.reset) def _show_password_dialog(self): if self._password_dialog: @@ -107,6 +108,8 @@ class MainWidget(QtWidgets.QWidget): self._password_dialog = None if password_passed: self.reset() + if not self.isVisible(): + self.show() else: self.close() @@ -141,7 +144,10 @@ class MainWidget(QtWidgets.QWidget): # Don't show dialog if there are not registered slots for # `trigger_restart` signal. # - For example when settings are runnin as standalone tool - if self.receivers(self.trigger_restart) < 1: + # - PySide2 and PyQt5 compatible way how to find out + method_index = self.metaObject().indexOfMethod("trigger_restart()") + method = self.metaObject().method(method_index) + if not self.isSignalConnected(method): return dialog = RestartDialog(self) diff --git a/openpype/tools/standalonepublish/widgets/widget_component_item.py b/openpype/tools/standalonepublish/widgets/widget_component_item.py index 186c8024db..de3cde50cd 100644 --- a/openpype/tools/standalonepublish/widgets/widget_component_item.py +++ b/openpype/tools/standalonepublish/widgets/widget_component_item.py @@ -1,7 +1,6 @@ import os from Qt import QtCore, QtGui, QtWidgets from .resources import get_resource -from avalon import style class ComponentItem(QtWidgets.QFrame): @@ -61,7 +60,7 @@ class ComponentItem(QtWidgets.QFrame): name="menu", size=QtCore.QSize(22, 22) ) - self.action_menu = QtWidgets.QMenu() + self.action_menu = QtWidgets.QMenu(self.btn_action_menu) expanding_sizePolicy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding @@ -229,7 +228,6 @@ class ComponentItem(QtWidgets.QFrame): if not self.btn_action_menu.isVisible(): self.btn_action_menu.setVisible(True) self.btn_action_menu.clicked.connect(self.show_actions) - self.action_menu.setStyleSheet(style.load_stylesheet()) def set_repre_name_valid(self, valid): self.has_valid_repre = valid diff --git a/openpype/tools/standalonepublish/widgets/widget_drop_frame.py b/openpype/tools/standalonepublish/widgets/widget_drop_frame.py index 63dcb82e83..7fe43c4203 100644 --- a/openpype/tools/standalonepublish/widgets/widget_drop_frame.py +++ b/openpype/tools/standalonepublish/widgets/widget_drop_frame.py @@ -211,7 +211,8 @@ class DropDataFrame(QtWidgets.QFrame): folder_path = os.path.dirname(collection.head) if file_base[-1] in ['.', '_']: file_base = file_base[:-1] - file_ext = collection.tail + file_ext = os.path.splitext( + collection.format('{head}{padding}{tail}'))[1] repr_name = file_ext.replace('.', '') range = collection.format('{ranges}') diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index d567e26d74..42f0e422ae 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -693,16 +693,16 @@ class FilesWidget(QtWidgets.QWidget): ) return - file_path = os.path.join(self.root, work_file) + file_path = os.path.join(os.path.normpath(self.root), work_file) - pipeline.emit("before.workfile.save", file_path) + pipeline.emit("before.workfile.save", [file_path]) self._enter_session() # Make sure we are in the right session self.host.save_file(file_path) self.set_asset_task(self._asset, self._task) - pipeline.emit("after.workfile.save", file_path) + pipeline.emit("after.workfile.save", [file_path]) self.workfile_created.emit(file_path) diff --git a/openpype/vendor/python/common/scriptsmenu/__init__.py b/openpype/vendor/python/common/scriptsmenu/__init__.py new file mode 100644 index 0000000000..a881f73533 --- /dev/null +++ b/openpype/vendor/python/common/scriptsmenu/__init__.py @@ -0,0 +1,5 @@ +from .scriptsmenu import ScriptsMenu +from . import version + +__all__ = ["ScriptsMenu"] +__version__ = version.version diff --git a/openpype/vendor/python/common/scriptsmenu/action.py b/openpype/vendor/python/common/scriptsmenu/action.py new file mode 100644 index 0000000000..dc4d775f6a --- /dev/null +++ b/openpype/vendor/python/common/scriptsmenu/action.py @@ -0,0 +1,207 @@ +import os + +from .vendor.Qt import QtWidgets + + +class Action(QtWidgets.QAction): + """Custom Action widget""" + + def __init__(self, parent=None): + + QtWidgets.QAction.__init__(self, parent) + + self._root = None + self._tags = list() + self._command = None + self._sourcetype = None + self._iconfile = None + self._label = None + + self._COMMAND = """import imp +f, filepath, descr = imp.find_module('{module_name}', ['{dirname}']) +module = imp.load_module('{module_name}', f, filepath, descr) +module.{module_name}()""" + + @property + def root(self): + return self._root + + @root.setter + def root(self, value): + self._root = value + + @property + def tags(self): + return self._tags + + @tags.setter + def tags(self, value): + self._tags = value + + @property + def command(self): + return self._command + + @command.setter + def command(self, value): + """ + Store the command in the QAction + + Args: + value (str): the full command which will be executed when clicked + + Return: + None + """ + self._command = value + + @property + def sourcetype(self): + return self._sourcetype + + @sourcetype.setter + def sourcetype(self, value): + """ + Set the command type to get the correct execution of the command given + + Args: + value (str): the name of the command type + + Returns: + None + + """ + self._sourcetype = value + + @property + def iconfile(self): + return self._iconfile + + @iconfile.setter + def iconfile(self, value): + """Store the path to the image file which needs to be displayed + + Args: + value (str): the path to the image + + Returns: + None + """ + self._iconfile = value + + @property + def label(self): + return self._label + + @label.setter + def label(self, value): + """ + Set the abbreviation which will be used as overlay text in the shelf + + Args: + value (str): an abbreviation of the name + + Returns: + None + + """ + self._label = value + + def run_command(self): + """ + Run the command of the instance or copy the command to the active shelf + based on the current modifiers. + + If callbacks have been registered with fouind modifier integer the + function will trigger all callbacks. When a callback function returns a + non zero integer it will not execute the action's command + + """ + + # get the current application and its linked keyboard modifiers + modifiers = QtWidgets.QApplication.keyboardModifiers() + + # If the menu has a callback registered for the current modifier + # we run the callback instead of the action itself. + registered = self._root.registered_callbacks + callbacks = registered.get(int(modifiers), []) + for callback in callbacks: + signal = callback(self) + if signal != 0: + # Exit function on non-zero return code + return + + exec(self.process_command()) + + def process_command(self): + """ + Check if the command is a file which needs to be launched and if it + has a relative path, if so return the full path by expanding + environment variables. Wrap any mel command in a executable string + for Python and return the string if the source type is + + Add your own source type and required process to ensure callback + is stored correctly. + + An example of a process is the sourcetype is MEL + (Maya Embedded Language) as Python cannot run it on its own so it + needs to be wrapped in a string in which we explicitly import mel and + run it as a mel.eval. The string is then parsed to python as + exec("command"). + + Returns: + str: a clean command which can be used + + """ + if self._sourcetype == "python": + return self._command + + if self._sourcetype == "mel": + # Escape single quotes + conversion = self._command.replace("'", "\\'") + return "import maya; maya.mel.eval('{}')".format(conversion) + + if self._sourcetype == "file": + if os.path.isabs(self._command): + filepath = self._command + else: + filepath = os.path.normpath(os.path.expandvars(self._command)) + + return self._wrap_filepath(filepath) + + def has_tag(self, tag): + """Check whether the tag matches with the action's tags. + + A partial match will also return True, for example tag `a` will match + correctly with the `ape` tag on the Action. + + Args: + tag (str): The tag + + Returns + bool: Whether the action is tagged with given tag + + """ + + for tagitem in self.tags: + if tag not in tagitem: + continue + return True + + return False + + def _wrap_filepath(self, file_path): + """Create a wrapped string for the python command + + Args: + file_path (str): the filepath of a script + + Returns: + str: the wrapped command + """ + + dirname = os.path.dirname(r"{}".format(file_path)) + dirpath = dirname.replace("\\", "/") + module_name = os.path.splitext(os.path.basename(file_path))[0] + + return self._COMMAND.format(module_name=module_name, dirname=dirpath) diff --git a/openpype/vendor/python/common/scriptsmenu/launchformari.py b/openpype/vendor/python/common/scriptsmenu/launchformari.py new file mode 100644 index 0000000000..25cfc80d96 --- /dev/null +++ b/openpype/vendor/python/common/scriptsmenu/launchformari.py @@ -0,0 +1,54 @@ + +# Import third-party modules +from vendor.Qt import QtWidgets + +# Import local modules +import scriptsmenu + + +def _mari_main_window(): + """Get Mari main window. + + Returns: + MriMainWindow: Mari's main window. + + """ + for obj in QtWidgets.QApplication.topLevelWidgets(): + if obj.metaObject().className() == 'MriMainWindow': + return obj + raise RuntimeError('Could not find Mari MainWindow instance') + + +def _mari_main_menubar(): + """Get Mari main menu bar. + + Returns: + Retrieve the main menubar of the Mari window. + + """ + mari_window = _mari_main_window() + menubar = [ + i for i in mari_window.children() if isinstance(i, QtWidgets.QMenuBar) + ] + assert len(menubar) == 1, "Error, could not find menu bar!" + return menubar[0] + + +def main(title="Scripts"): + """Build the main scripts menu in Mari. + + Args: + title (str): Name of the menu in the application. + + Returns: + scriptsmenu.ScriptsMenu: Instance object. + + """ + mari_main_bar = _mari_main_menubar() + for mari_bar in mari_main_bar.children(): + if isinstance(mari_bar, scriptsmenu.ScriptsMenu): + if mari_bar.title() == title: + menu = mari_bar + return menu + menu = scriptsmenu.ScriptsMenu(title=title, parent=mari_main_bar) + return menu diff --git a/openpype/vendor/python/common/scriptsmenu/launchformaya.py b/openpype/vendor/python/common/scriptsmenu/launchformaya.py new file mode 100644 index 0000000000..7ad66f0ad2 --- /dev/null +++ b/openpype/vendor/python/common/scriptsmenu/launchformaya.py @@ -0,0 +1,137 @@ +import logging + +import maya.cmds as cmds +import maya.mel as mel + +import scriptsmenu +from .vendor.Qt import QtCore, QtWidgets + +log = logging.getLogger(__name__) + + +def register_repeat_last(action): + """Register the action in repeatLast to ensure the RepeatLast hotkey works + + Args: + action (action.Action): Action wigdet instance + + Returns: + int: 0 + + """ + command = action.process_command() + command = command.replace("\n", "; ") + # Register command to Maya (mel) + cmds.repeatLast(addCommand='python("{}")'.format(command), + addCommandLabel=action.label) + + return 0 + + +def to_shelf(action): + """Copy clicked menu item to the currently active Maya shelf + Args: + action (action.Action): the action instance which is clicked + + Returns: + int: 1 + + """ + + shelftoplevel = mel.eval("$gShelfTopLevel = $gShelfTopLevel;") + current_active_shelf = cmds.tabLayout(shelftoplevel, + query=True, + selectTab=True) + + cmds.shelfButton(command=action.process_command(), + sourceType="python", + parent=current_active_shelf, + image=action.iconfile or "pythonFamily.png", + annotation=action.statusTip(), + imageOverlayLabel=action.label or "") + + return 1 + + +def _maya_main_window(): + """Return Maya's main window""" + for obj in QtWidgets.QApplication.topLevelWidgets(): + if obj.objectName() == 'MayaWindow': + return obj + raise RuntimeError('Could not find MayaWindow instance') + + +def _maya_main_menubar(): + """Retrieve the main menubar of the Maya window""" + mayawindow = _maya_main_window() + menubar = [i for i in mayawindow.children() + if isinstance(i, QtWidgets.QMenuBar)] + + assert len(menubar) == 1, "Error, could not find menu bar!" + + return menubar[0] + + +def find_scripts_menu(title, parent): + """ + Check if the menu exists with the given title in the parent + + Args: + title (str): the title name of the scripts menu + + parent (QtWidgets.QMenuBar): the menubar to check + + Returns: + QtWidgets.QMenu or None + + """ + + menu = None + search = [i for i in parent.children() if + isinstance(i, scriptsmenu.ScriptsMenu) + and i.title() == title] + + if search: + assert len(search) < 2, ("Multiple instances of menu '{}' " + "in menu bar".format(title)) + menu = search[0] + + return menu + + +def main(title="Scripts", parent=None, objectName=None): + """Build the main scripts menu in Maya + + Args: + title (str): name of the menu in the application + + parent (QtWidgets.QtMenuBar): the parent object for the menu + + objectName (str): custom objectName for scripts menu + + Returns: + scriptsmenu.ScriptsMenu instance + + """ + + mayamainbar = parent or _maya_main_menubar() + try: + # check menu already exists + menu = find_scripts_menu(title, mayamainbar) + if not menu: + log.info("Attempting to build menu ...") + object_name = objectName or title.lower() + menu = scriptsmenu.ScriptsMenu(title=title, + parent=mayamainbar, + objectName=object_name) + except Exception as e: + log.error(e) + return + + # Register control + shift callback to add to shelf (maya behavior) + modifiers = QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier + menu.register_callback(int(modifiers), to_shelf) + + menu.register_callback(0, register_repeat_last) + + return menu diff --git a/openpype/vendor/python/common/scriptsmenu/launchfornuke.py b/openpype/vendor/python/common/scriptsmenu/launchfornuke.py new file mode 100644 index 0000000000..23e4ed1b4d --- /dev/null +++ b/openpype/vendor/python/common/scriptsmenu/launchfornuke.py @@ -0,0 +1,36 @@ +import scriptsmenu +from .vendor.Qt import QtWidgets + + +def _nuke_main_window(): + """Return Nuke's main window""" + for obj in QtWidgets.QApplication.topLevelWidgets(): + if (obj.inherits('QMainWindow') and + obj.metaObject().className() == 'Foundry::UI::DockMainWindow'): + return obj + raise RuntimeError('Could not find Nuke MainWindow instance') + + +def _nuke_main_menubar(): + """Retrieve the main menubar of the Nuke window""" + nuke_window = _nuke_main_window() + menubar = [i for i in nuke_window.children() + if isinstance(i, QtWidgets.QMenuBar)] + + assert len(menubar) == 1, "Error, could not find menu bar!" + return menubar[0] + + +def main(title="Scripts"): + # Register control + shift callback to add to shelf (Nuke behavior) + # modifiers = QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier + # menu.register_callback(modifiers, to_shelf) + nuke_main_bar = _nuke_main_menubar() + for nuke_bar in nuke_main_bar.children(): + if isinstance(nuke_bar, scriptsmenu.ScriptsMenu): + if nuke_bar.title() == title: + menu = nuke_bar + return menu + + menu = scriptsmenu.ScriptsMenu(title=title, parent=nuke_main_bar) + return menu \ No newline at end of file diff --git a/openpype/vendor/python/common/scriptsmenu/scriptsmenu.py b/openpype/vendor/python/common/scriptsmenu/scriptsmenu.py new file mode 100644 index 0000000000..e2b7ff96c7 --- /dev/null +++ b/openpype/vendor/python/common/scriptsmenu/scriptsmenu.py @@ -0,0 +1,316 @@ +import os +import json +import logging +from collections import defaultdict + +from .vendor.Qt import QtWidgets, QtCore +from . import action + +log = logging.getLogger(__name__) + + +class ScriptsMenu(QtWidgets.QMenu): + """A Qt menu that displays a list of searchable actions""" + + updated = QtCore.Signal(QtWidgets.QMenu) + + def __init__(self, *args, **kwargs): + """Initialize Scripts menu + + Args: + title (str): the name of the root menu which will be created + + parent (QtWidgets.QObject) : the QObject to parent the menu to + + Returns: + None + + """ + QtWidgets.QMenu.__init__(self, *args, **kwargs) + + self.searchbar = None + self.update_action = None + + self._script_actions = [] + self._callbacks = defaultdict(list) + + # Automatically add it to the parent menu + parent = kwargs.get("parent", None) + if parent: + parent.addMenu(self) + + objectname = kwargs.get("objectName", "scripts") + title = kwargs.get("title", "Scripts") + self.setObjectName(objectname) + self.setTitle(title) + + # add default items in the menu + self.create_default_items() + + def on_update(self): + self.updated.emit(self) + + @property + def registered_callbacks(self): + return self._callbacks.copy() + + def create_default_items(self): + """Add a search bar to the top of the menu given""" + + # create widget and link function + searchbar = QtWidgets.QLineEdit() + searchbar.setFixedWidth(120) + searchbar.setPlaceholderText("Search ...") + searchbar.textChanged.connect(self._update_search) + self.searchbar = searchbar + + # create widget holder + searchbar_action = QtWidgets.QWidgetAction(self) + + # add widget to widget holder + searchbar_action.setDefaultWidget(self.searchbar) + searchbar_action.setObjectName("Searchbar") + + # add update button and link function + update_action = QtWidgets.QAction(self) + update_action.setObjectName("Update Scripts") + update_action.setText("Update Scripts") + update_action.setVisible(False) + update_action.triggered.connect(self.on_update) + self.update_action = update_action + + # add action to menu + self.addAction(searchbar_action) + self.addAction(update_action) + + # add separator object + separator = self.addSeparator() + separator.setObjectName("separator") + + def add_menu(self, title, parent=None): + """Create a sub menu for a parent widget + + Args: + parent(QtWidgets.QWidget): the object to parent the menu to + + title(str): the title of the menu + + Returns: + QtWidget.QMenu instance + """ + + if not parent: + parent = self + + menu = QtWidgets.QMenu(parent, title) + menu.setTitle(title) + menu.setObjectName(title) + menu.setTearOffEnabled(True) + parent.addMenu(menu) + + return menu + + def add_script(self, parent, title, command, sourcetype, icon=None, + tags=None, label=None, tooltip=None): + """Create an action item which runs a script when clicked + + Args: + parent (QtWidget.QWidget): The widget to parent the item to + + title (str): The text which will be displayed in the menu + + command (str): The command which needs to be run when the item is + clicked. + + sourcetype (str): The type of command, the way the command is + processed is based on the source type. + + icon (str): The file path of an icon to display with the menu item + + tags (list, tuple): Keywords which describe the action + + label (str): A short description of the script which will be displayed + when hovering over the menu item + + tooltip (str): A tip for the user about the usage fo the tool + + Returns: + QtWidget.QAction instance + + """ + + assert tags is None or isinstance(tags, (list, tuple)) + # Ensure tags is a list + tags = list() if tags is None else list(tags) + tags.append(title.lower()) + + assert icon is None or isinstance(icon, str), ( + "Invalid data type for icon, supported : None, string") + + # create new action + script_action = action.Action(parent) + script_action.setText(title) + script_action.setObjectName(title) + script_action.tags = tags + + # link action to root for callback library + script_action.root = self + + # Set up the command + script_action.sourcetype = sourcetype + script_action.command = command + + try: + script_action.process_command() + except RuntimeError as e: + raise RuntimeError("Script action can't be " + "processed: {}".format(e)) + + if icon: + iconfile = os.path.expandvars(icon) + script_action.iconfile = iconfile + script_action_icon = QtWidgets.QIcon(iconfile) + script_action.setIcon(script_action_icon) + + if label: + script_action.label = label + + if tooltip: + script_action.setStatusTip(tooltip) + + script_action.triggered.connect(script_action.run_command) + parent.addAction(script_action) + + # Add to our searchable actions + self._script_actions.append(script_action) + + return script_action + + def build_from_configuration(self, parent, configuration): + """Process the configurations and store the configuration + + This creates all submenus from a configuration.json file. + + When the configuration holds the key `main` all scripts under `main` will + be added to the main menu first before adding the rest + + Args: + parent (ScriptsMenu): script menu instance + configuration (list): A ScriptsMenu configuration list + + Returns: + None + + """ + + for item in configuration: + assert isinstance(item, dict), "Configuration is wrong!" + + # skip items which have no `type` key + item_type = item.get('type', None) + if not item_type: + log.warning("Missing 'type' from configuration item") + continue + + # add separator + # Special behavior for separators + if item_type == "separator": + parent.addSeparator() + + # add submenu + # items should hold a collection of submenu items (dict) + elif item_type == "menu": + assert "items" in item, "Menu is missing 'items' key" + menu = self.add_menu(parent=parent, title=item["title"]) + self.build_from_configuration(menu, item["items"]) + + # add script + elif item_type == "action": + # filter out `type` from the item dict + config = {key: value for key, value in + item.items() if key != "type"} + + self.add_script(parent=parent, **config) + + def set_update_visible(self, state): + self.update_action.setVisible(state) + + def clear_menu(self): + """Clear all menu items which are not default + + Returns: + None + + """ + + # TODO: Set up a more robust implementation for this + # Delete all except the first three actions + for _action in self.actions()[3:]: + self.removeAction(_action) + + def register_callback(self, modifiers, callback): + self._callbacks[modifiers].append(callback) + + def _update_search(self, search): + """Hide all the samples which do not match the user's import + + Returns: + None + + """ + + if not search: + for action in self._script_actions: + action.setVisible(True) + else: + for action in self._script_actions: + if not action.has_tag(search.lower()): + action.setVisible(False) + + # Set visibility for all submenus + for action in self.actions(): + if not action.menu(): + continue + + menu = action.menu() + visible = any(action.isVisible() for action in menu.actions()) + action.setVisible(visible) + + +def load_configuration(path): + """Load the configuration from a file + + Read out the JSON file which will dictate the structure of the scripts menu + + Args: + path (str): file path of the .JSON file + + Returns: + dict + + """ + + if not os.path.isfile(path): + raise AttributeError("Given configuration is not " + "a file!\n'{}'".format(path)) + + extension = os.path.splitext(path)[-1] + if extension != ".json": + raise AttributeError("Given configuration file has unsupported " + "file type, provide a .json file") + + # retrieve and store config + with open(path, "r") as f: + configuration = json.load(f) + + return configuration + + +def application(configuration, parent): + import sys + app = QtWidgets.QApplication(sys.argv) + + scriptsmenu = ScriptsMenu(configuration, parent) + scriptsmenu.show() + + sys.exit(app.exec_()) diff --git a/openpype/vendor/python/common/scriptsmenu/vendor/Qt.py b/openpype/vendor/python/common/scriptsmenu/vendor/Qt.py new file mode 100644 index 0000000000..fe4b45f18f --- /dev/null +++ b/openpype/vendor/python/common/scriptsmenu/vendor/Qt.py @@ -0,0 +1,1989 @@ +"""Minimal Python 2 & 3 shim around all Qt bindings + +DOCUMENTATION + Qt.py was born in the film and visual effects industry to address + the growing need for the development of software capable of running + with more than one flavour of the Qt bindings for Python - PySide, + PySide2, PyQt4 and PyQt5. + + 1. Build for one, run with all + 2. Explicit is better than implicit + 3. Support co-existence + + Default resolution order: + - PySide2 + - PyQt5 + - PySide + - PyQt4 + + Usage: + >> import sys + >> from Qt import QtWidgets + >> app = QtWidgets.QApplication(sys.argv) + >> button = QtWidgets.QPushButton("Hello World") + >> button.show() + >> app.exec_() + + All members of PySide2 are mapped from other bindings, should they exist. + If no equivalent member exist, it is excluded from Qt.py and inaccessible. + The idea is to highlight members that exist across all supported binding, + and guarantee that code that runs on one binding runs on all others. + + For more details, visit https://github.com/mottosso/Qt.py + +LICENSE + + See end of file for license (MIT, BSD) information. + +""" + +import os +import sys +import types +import shutil +import importlib + + +__version__ = "1.2.3" + +# Enable support for `from Qt import *` +__all__ = [] + +# Flags from environment variables +QT_VERBOSE = bool(os.getenv("QT_VERBOSE")) +QT_PREFERRED_BINDING = os.getenv("QT_PREFERRED_BINDING", "") +QT_SIP_API_HINT = os.getenv("QT_SIP_API_HINT") + +# Reference to Qt.py +Qt = sys.modules[__name__] +Qt.QtCompat = types.ModuleType("QtCompat") + +try: + long +except NameError: + # Python 3 compatibility + long = int + + +"""Common members of all bindings + +This is where each member of Qt.py is explicitly defined. +It is based on a "lowest common denominator" of all bindings; +including members found in each of the 4 bindings. + +The "_common_members" dictionary is generated using the +build_membership.sh script. + +""" + +_common_members = { + "QtCore": [ + "QAbstractAnimation", + "QAbstractEventDispatcher", + "QAbstractItemModel", + "QAbstractListModel", + "QAbstractState", + "QAbstractTableModel", + "QAbstractTransition", + "QAnimationGroup", + "QBasicTimer", + "QBitArray", + "QBuffer", + "QByteArray", + "QByteArrayMatcher", + "QChildEvent", + "QCoreApplication", + "QCryptographicHash", + "QDataStream", + "QDate", + "QDateTime", + "QDir", + "QDirIterator", + "QDynamicPropertyChangeEvent", + "QEasingCurve", + "QElapsedTimer", + "QEvent", + "QEventLoop", + "QEventTransition", + "QFile", + "QFileInfo", + "QFileSystemWatcher", + "QFinalState", + "QGenericArgument", + "QGenericReturnArgument", + "QHistoryState", + "QItemSelectionRange", + "QIODevice", + "QLibraryInfo", + "QLine", + "QLineF", + "QLocale", + "QMargins", + "QMetaClassInfo", + "QMetaEnum", + "QMetaMethod", + "QMetaObject", + "QMetaProperty", + "QMimeData", + "QModelIndex", + "QMutex", + "QMutexLocker", + "QObject", + "QParallelAnimationGroup", + "QPauseAnimation", + "QPersistentModelIndex", + "QPluginLoader", + "QPoint", + "QPointF", + "QProcess", + "QProcessEnvironment", + "QPropertyAnimation", + "QReadLocker", + "QReadWriteLock", + "QRect", + "QRectF", + "QRegExp", + "QResource", + "QRunnable", + "QSemaphore", + "QSequentialAnimationGroup", + "QSettings", + "QSignalMapper", + "QSignalTransition", + "QSize", + "QSizeF", + "QSocketNotifier", + "QState", + "QStateMachine", + "QSysInfo", + "QSystemSemaphore", + "QT_TRANSLATE_NOOP", + "QT_TR_NOOP", + "QT_TR_NOOP_UTF8", + "QTemporaryFile", + "QTextBoundaryFinder", + "QTextCodec", + "QTextDecoder", + "QTextEncoder", + "QTextStream", + "QTextStreamManipulator", + "QThread", + "QThreadPool", + "QTime", + "QTimeLine", + "QTimer", + "QTimerEvent", + "QTranslator", + "QUrl", + "QVariantAnimation", + "QWaitCondition", + "QWriteLocker", + "QXmlStreamAttribute", + "QXmlStreamAttributes", + "QXmlStreamEntityDeclaration", + "QXmlStreamEntityResolver", + "QXmlStreamNamespaceDeclaration", + "QXmlStreamNotationDeclaration", + "QXmlStreamReader", + "QXmlStreamWriter", + "Qt", + "QtCriticalMsg", + "QtDebugMsg", + "QtFatalMsg", + "QtMsgType", + "QtSystemMsg", + "QtWarningMsg", + "qAbs", + "qAddPostRoutine", + "qChecksum", + "qCritical", + "qDebug", + "qFatal", + "qFuzzyCompare", + "qIsFinite", + "qIsInf", + "qIsNaN", + "qIsNull", + "qRegisterResourceData", + "qUnregisterResourceData", + "qVersion", + "qWarning", + "qrand", + "qsrand" + ], + "QtGui": [ + "QAbstractTextDocumentLayout", + "QActionEvent", + "QBitmap", + "QBrush", + "QClipboard", + "QCloseEvent", + "QColor", + "QConicalGradient", + "QContextMenuEvent", + "QCursor", + "QDesktopServices", + "QDoubleValidator", + "QDrag", + "QDragEnterEvent", + "QDragLeaveEvent", + "QDragMoveEvent", + "QDropEvent", + "QFileOpenEvent", + "QFocusEvent", + "QFont", + "QFontDatabase", + "QFontInfo", + "QFontMetrics", + "QFontMetricsF", + "QGradient", + "QHelpEvent", + "QHideEvent", + "QHoverEvent", + "QIcon", + "QIconDragEvent", + "QIconEngine", + "QImage", + "QImageIOHandler", + "QImageReader", + "QImageWriter", + "QInputEvent", + "QInputMethodEvent", + "QIntValidator", + "QKeyEvent", + "QKeySequence", + "QLinearGradient", + "QMatrix2x2", + "QMatrix2x3", + "QMatrix2x4", + "QMatrix3x2", + "QMatrix3x3", + "QMatrix3x4", + "QMatrix4x2", + "QMatrix4x3", + "QMatrix4x4", + "QMouseEvent", + "QMoveEvent", + "QMovie", + "QPaintDevice", + "QPaintEngine", + "QPaintEngineState", + "QPaintEvent", + "QPainter", + "QPainterPath", + "QPainterPathStroker", + "QPalette", + "QPen", + "QPicture", + "QPictureIO", + "QPixmap", + "QPixmapCache", + "QPolygon", + "QPolygonF", + "QQuaternion", + "QRadialGradient", + "QRegExpValidator", + "QRegion", + "QResizeEvent", + "QSessionManager", + "QShortcutEvent", + "QShowEvent", + "QStandardItem", + "QStandardItemModel", + "QStatusTipEvent", + "QSyntaxHighlighter", + "QTabletEvent", + "QTextBlock", + "QTextBlockFormat", + "QTextBlockGroup", + "QTextBlockUserData", + "QTextCharFormat", + "QTextCursor", + "QTextDocument", + "QTextDocumentFragment", + "QTextFormat", + "QTextFragment", + "QTextFrame", + "QTextFrameFormat", + "QTextImageFormat", + "QTextInlineObject", + "QTextItem", + "QTextLayout", + "QTextLength", + "QTextLine", + "QTextList", + "QTextListFormat", + "QTextObject", + "QTextObjectInterface", + "QTextOption", + "QTextTable", + "QTextTableCell", + "QTextTableCellFormat", + "QTextTableFormat", + "QTouchEvent", + "QTransform", + "QValidator", + "QVector2D", + "QVector3D", + "QVector4D", + "QWhatsThisClickedEvent", + "QWheelEvent", + "QWindowStateChangeEvent", + "qAlpha", + "qBlue", + "qGray", + "qGreen", + "qIsGray", + "qRed", + "qRgb", + "qRgba" + ], + "QtHelp": [ + "QHelpContentItem", + "QHelpContentModel", + "QHelpContentWidget", + "QHelpEngine", + "QHelpEngineCore", + "QHelpIndexModel", + "QHelpIndexWidget", + "QHelpSearchEngine", + "QHelpSearchQuery", + "QHelpSearchQueryWidget", + "QHelpSearchResultWidget" + ], + "QtMultimedia": [ + "QAbstractVideoBuffer", + "QAbstractVideoSurface", + "QAudio", + "QAudioDeviceInfo", + "QAudioFormat", + "QAudioInput", + "QAudioOutput", + "QVideoFrame", + "QVideoSurfaceFormat" + ], + "QtNetwork": [ + "QAbstractNetworkCache", + "QAbstractSocket", + "QAuthenticator", + "QHostAddress", + "QHostInfo", + "QLocalServer", + "QLocalSocket", + "QNetworkAccessManager", + "QNetworkAddressEntry", + "QNetworkCacheMetaData", + "QNetworkConfiguration", + "QNetworkConfigurationManager", + "QNetworkCookie", + "QNetworkCookieJar", + "QNetworkDiskCache", + "QNetworkInterface", + "QNetworkProxy", + "QNetworkProxyFactory", + "QNetworkProxyQuery", + "QNetworkReply", + "QNetworkRequest", + "QNetworkSession", + "QSsl", + "QTcpServer", + "QTcpSocket", + "QUdpSocket" + ], + "QtOpenGL": [ + "QGL", + "QGLContext", + "QGLFormat", + "QGLWidget" + ], + "QtPrintSupport": [ + "QAbstractPrintDialog", + "QPageSetupDialog", + "QPrintDialog", + "QPrintEngine", + "QPrintPreviewDialog", + "QPrintPreviewWidget", + "QPrinter", + "QPrinterInfo" + ], + "QtSql": [ + "QSql", + "QSqlDatabase", + "QSqlDriver", + "QSqlDriverCreatorBase", + "QSqlError", + "QSqlField", + "QSqlIndex", + "QSqlQuery", + "QSqlQueryModel", + "QSqlRecord", + "QSqlRelation", + "QSqlRelationalDelegate", + "QSqlRelationalTableModel", + "QSqlResult", + "QSqlTableModel" + ], + "QtSvg": [ + "QGraphicsSvgItem", + "QSvgGenerator", + "QSvgRenderer", + "QSvgWidget" + ], + "QtTest": [ + "QTest" + ], + "QtWidgets": [ + "QAbstractButton", + "QAbstractGraphicsShapeItem", + "QAbstractItemDelegate", + "QAbstractItemView", + "QAbstractScrollArea", + "QAbstractSlider", + "QAbstractSpinBox", + "QAction", + "QActionGroup", + "QApplication", + "QBoxLayout", + "QButtonGroup", + "QCalendarWidget", + "QCheckBox", + "QColorDialog", + "QColumnView", + "QComboBox", + "QCommandLinkButton", + "QCommonStyle", + "QCompleter", + "QDataWidgetMapper", + "QDateEdit", + "QDateTimeEdit", + "QDesktopWidget", + "QDial", + "QDialog", + "QDialogButtonBox", + "QDirModel", + "QDockWidget", + "QDoubleSpinBox", + "QErrorMessage", + "QFileDialog", + "QFileIconProvider", + "QFileSystemModel", + "QFocusFrame", + "QFontComboBox", + "QFontDialog", + "QFormLayout", + "QFrame", + "QGesture", + "QGestureEvent", + "QGestureRecognizer", + "QGraphicsAnchor", + "QGraphicsAnchorLayout", + "QGraphicsBlurEffect", + "QGraphicsColorizeEffect", + "QGraphicsDropShadowEffect", + "QGraphicsEffect", + "QGraphicsEllipseItem", + "QGraphicsGridLayout", + "QGraphicsItem", + "QGraphicsItemGroup", + "QGraphicsLayout", + "QGraphicsLayoutItem", + "QGraphicsLineItem", + "QGraphicsLinearLayout", + "QGraphicsObject", + "QGraphicsOpacityEffect", + "QGraphicsPathItem", + "QGraphicsPixmapItem", + "QGraphicsPolygonItem", + "QGraphicsProxyWidget", + "QGraphicsRectItem", + "QGraphicsRotation", + "QGraphicsScale", + "QGraphicsScene", + "QGraphicsSceneContextMenuEvent", + "QGraphicsSceneDragDropEvent", + "QGraphicsSceneEvent", + "QGraphicsSceneHelpEvent", + "QGraphicsSceneHoverEvent", + "QGraphicsSceneMouseEvent", + "QGraphicsSceneMoveEvent", + "QGraphicsSceneResizeEvent", + "QGraphicsSceneWheelEvent", + "QGraphicsSimpleTextItem", + "QGraphicsTextItem", + "QGraphicsTransform", + "QGraphicsView", + "QGraphicsWidget", + "QGridLayout", + "QGroupBox", + "QHBoxLayout", + "QHeaderView", + "QInputDialog", + "QItemDelegate", + "QItemEditorCreatorBase", + "QItemEditorFactory", + "QKeyEventTransition", + "QLCDNumber", + "QLabel", + "QLayout", + "QLayoutItem", + "QLineEdit", + "QListView", + "QListWidget", + "QListWidgetItem", + "QMainWindow", + "QMdiArea", + "QMdiSubWindow", + "QMenu", + "QMenuBar", + "QMessageBox", + "QMouseEventTransition", + "QPanGesture", + "QPinchGesture", + "QPlainTextDocumentLayout", + "QPlainTextEdit", + "QProgressBar", + "QProgressDialog", + "QPushButton", + "QRadioButton", + "QRubberBand", + "QScrollArea", + "QScrollBar", + "QShortcut", + "QSizeGrip", + "QSizePolicy", + "QSlider", + "QSpacerItem", + "QSpinBox", + "QSplashScreen", + "QSplitter", + "QSplitterHandle", + "QStackedLayout", + "QStackedWidget", + "QStatusBar", + "QStyle", + "QStyleFactory", + "QStyleHintReturn", + "QStyleHintReturnMask", + "QStyleHintReturnVariant", + "QStyleOption", + "QStyleOptionButton", + "QStyleOptionComboBox", + "QStyleOptionComplex", + "QStyleOptionDockWidget", + "QStyleOptionFocusRect", + "QStyleOptionFrame", + "QStyleOptionGraphicsItem", + "QStyleOptionGroupBox", + "QStyleOptionHeader", + "QStyleOptionMenuItem", + "QStyleOptionProgressBar", + "QStyleOptionRubberBand", + "QStyleOptionSizeGrip", + "QStyleOptionSlider", + "QStyleOptionSpinBox", + "QStyleOptionTab", + "QStyleOptionTabBarBase", + "QStyleOptionTabWidgetFrame", + "QStyleOptionTitleBar", + "QStyleOptionToolBar", + "QStyleOptionToolBox", + "QStyleOptionToolButton", + "QStyleOptionViewItem", + "QStylePainter", + "QStyledItemDelegate", + "QSwipeGesture", + "QSystemTrayIcon", + "QTabBar", + "QTabWidget", + "QTableView", + "QTableWidget", + "QTableWidgetItem", + "QTableWidgetSelectionRange", + "QTapAndHoldGesture", + "QTapGesture", + "QTextBrowser", + "QTextEdit", + "QTimeEdit", + "QToolBar", + "QToolBox", + "QToolButton", + "QToolTip", + "QTreeView", + "QTreeWidget", + "QTreeWidgetItem", + "QTreeWidgetItemIterator", + "QUndoCommand", + "QUndoGroup", + "QUndoStack", + "QUndoView", + "QVBoxLayout", + "QWhatsThis", + "QWidget", + "QWidgetAction", + "QWidgetItem", + "QWizard", + "QWizardPage" + ], + "QtX11Extras": [ + "QX11Info" + ], + "QtXml": [ + "QDomAttr", + "QDomCDATASection", + "QDomCharacterData", + "QDomComment", + "QDomDocument", + "QDomDocumentFragment", + "QDomDocumentType", + "QDomElement", + "QDomEntity", + "QDomEntityReference", + "QDomImplementation", + "QDomNamedNodeMap", + "QDomNode", + "QDomNodeList", + "QDomNotation", + "QDomProcessingInstruction", + "QDomText", + "QXmlAttributes", + "QXmlContentHandler", + "QXmlDTDHandler", + "QXmlDeclHandler", + "QXmlDefaultHandler", + "QXmlEntityResolver", + "QXmlErrorHandler", + "QXmlInputSource", + "QXmlLexicalHandler", + "QXmlLocator", + "QXmlNamespaceSupport", + "QXmlParseException", + "QXmlReader", + "QXmlSimpleReader" + ], + "QtXmlPatterns": [ + "QAbstractMessageHandler", + "QAbstractUriResolver", + "QAbstractXmlNodeModel", + "QAbstractXmlReceiver", + "QSourceLocation", + "QXmlFormatter", + "QXmlItem", + "QXmlName", + "QXmlNamePool", + "QXmlNodeModelIndex", + "QXmlQuery", + "QXmlResultItems", + "QXmlSchema", + "QXmlSchemaValidator", + "QXmlSerializer" + ] +} + +""" Missing members + +This mapping describes members that have been deprecated +in one or more bindings and have been left out of the +_common_members mapping. + +The member can provide an extra details string to be +included in exceptions and warnings. +""" + +_missing_members = { + "QtGui": { + "QMatrix": "Deprecated in PyQt5", + }, +} + + +def _qInstallMessageHandler(handler): + """Install a message handler that works in all bindings + + Args: + handler: A function that takes 3 arguments, or None + """ + def messageOutputHandler(*args): + # In Qt4 bindings, message handlers are passed 2 arguments + # In Qt5 bindings, message handlers are passed 3 arguments + # The first argument is a QtMsgType + # The last argument is the message to be printed + # The Middle argument (if passed) is a QMessageLogContext + if len(args) == 3: + msgType, logContext, msg = args + elif len(args) == 2: + msgType, msg = args + logContext = None + else: + raise TypeError( + "handler expected 2 or 3 arguments, got {0}".format(len(args))) + + if isinstance(msg, bytes): + # In python 3, some bindings pass a bytestring, which cannot be + # used elsewhere. Decoding a python 2 or 3 bytestring object will + # consistently return a unicode object. + msg = msg.decode() + + handler(msgType, logContext, msg) + + passObject = messageOutputHandler if handler else handler + if Qt.IsPySide or Qt.IsPyQt4: + return Qt._QtCore.qInstallMsgHandler(passObject) + elif Qt.IsPySide2 or Qt.IsPyQt5: + return Qt._QtCore.qInstallMessageHandler(passObject) + + +def _getcpppointer(object): + if hasattr(Qt, "_shiboken2"): + return getattr(Qt, "_shiboken2").getCppPointer(object)[0] + elif hasattr(Qt, "_shiboken"): + return getattr(Qt, "_shiboken").getCppPointer(object)[0] + elif hasattr(Qt, "_sip"): + return getattr(Qt, "_sip").unwrapinstance(object) + raise AttributeError("'module' has no attribute 'getCppPointer'") + + +def _wrapinstance(ptr, base=None): + """Enable implicit cast of pointer to most suitable class + + This behaviour is available in sip per default. + + Based on http://nathanhorne.com/pyqtpyside-wrap-instance + + Usage: + This mechanism kicks in under these circumstances. + 1. Qt.py is using PySide 1 or 2. + 2. A `base` argument is not provided. + + See :func:`QtCompat.wrapInstance()` + + Arguments: + ptr (long): Pointer to QObject in memory + base (QObject, optional): Base class to wrap with. Defaults to QObject, + which should handle anything. + + """ + + assert isinstance(ptr, long), "Argument 'ptr' must be of type " + assert (base is None) or issubclass(base, Qt.QtCore.QObject), ( + "Argument 'base' must be of type ") + + if Qt.IsPyQt4 or Qt.IsPyQt5: + func = getattr(Qt, "_sip").wrapinstance + elif Qt.IsPySide2: + func = getattr(Qt, "_shiboken2").wrapInstance + elif Qt.IsPySide: + func = getattr(Qt, "_shiboken").wrapInstance + else: + raise AttributeError("'module' has no attribute 'wrapInstance'") + + if base is None: + q_object = func(long(ptr), Qt.QtCore.QObject) + meta_object = q_object.metaObject() + class_name = meta_object.className() + super_class_name = meta_object.superClass().className() + + if hasattr(Qt.QtWidgets, class_name): + base = getattr(Qt.QtWidgets, class_name) + + elif hasattr(Qt.QtWidgets, super_class_name): + base = getattr(Qt.QtWidgets, super_class_name) + + else: + base = Qt.QtCore.QObject + + return func(long(ptr), base) + + +def _isvalid(object): + """Check if the object is valid to use in Python runtime. + + Usage: + See :func:`QtCompat.isValid()` + + Arguments: + object (QObject): QObject to check the validity of. + + """ + + assert isinstance(object, Qt.QtCore.QObject) + + if hasattr(Qt, "_shiboken2"): + return getattr(Qt, "_shiboken2").isValid(object) + + elif hasattr(Qt, "_shiboken"): + return getattr(Qt, "_shiboken").isValid(object) + + elif hasattr(Qt, "_sip"): + return not getattr(Qt, "_sip").isdeleted(object) + + else: + raise AttributeError("'module' has no attribute isValid") + + +def _translate(context, sourceText, *args): + # In Qt4 bindings, translate can be passed 2 or 3 arguments + # In Qt5 bindings, translate can be passed 2 arguments + # The first argument is disambiguation[str] + # The last argument is n[int] + # The middle argument can be encoding[QtCore.QCoreApplication.Encoding] + if len(args) == 3: + disambiguation, encoding, n = args + elif len(args) == 2: + disambiguation, n = args + encoding = None + else: + raise TypeError( + "Expected 4 or 5 arguments, got {0}.".format(len(args) + 2)) + + if hasattr(Qt.QtCore, "QCoreApplication"): + app = getattr(Qt.QtCore, "QCoreApplication") + else: + raise NotImplementedError( + "Missing QCoreApplication implementation for {binding}".format( + binding=Qt.__binding__, + ) + ) + if Qt.__binding__ in ("PySide2", "PyQt5"): + sanitized_args = [context, sourceText, disambiguation, n] + else: + sanitized_args = [ + context, + sourceText, + disambiguation, + encoding or app.CodecForTr, + n + ] + return app.translate(*sanitized_args) + + +def _loadUi(uifile, baseinstance=None): + """Dynamically load a user interface from the given `uifile` + + This function calls `uic.loadUi` if using PyQt bindings, + else it implements a comparable binding for PySide. + + Documentation: + http://pyqt.sourceforge.net/Docs/PyQt5/designer.html#PyQt5.uic.loadUi + + Arguments: + uifile (str): Absolute path to Qt Designer file. + baseinstance (QWidget): Instantiated QWidget or subclass thereof + + Return: + baseinstance if `baseinstance` is not `None`. Otherwise + return the newly created instance of the user interface. + + """ + if hasattr(Qt, "_uic"): + return Qt._uic.loadUi(uifile, baseinstance) + + elif hasattr(Qt, "_QtUiTools"): + # Implement `PyQt5.uic.loadUi` for PySide(2) + + class _UiLoader(Qt._QtUiTools.QUiLoader): + """Create the user interface in a base instance. + + Unlike `Qt._QtUiTools.QUiLoader` itself this class does not + create a new instance of the top-level widget, but creates the user + interface in an existing instance of the top-level class if needed. + + This mimics the behaviour of `PyQt5.uic.loadUi`. + + """ + + def __init__(self, baseinstance): + super(_UiLoader, self).__init__(baseinstance) + self.baseinstance = baseinstance + self.custom_widgets = {} + + def _loadCustomWidgets(self, etree): + """ + Workaround to pyside-77 bug. + + From QUiLoader doc we should use registerCustomWidget method. + But this causes a segfault on some platforms. + + Instead we fetch from customwidgets DOM node the python class + objects. Then we can directly use them in createWidget method. + """ + + def headerToModule(header): + """ + Translate a header file to python module path + foo/bar.h => foo.bar + """ + # Remove header extension + module = os.path.splitext(header)[0] + + # Replace os separator by python module separator + return module.replace("/", ".").replace("\\", ".") + + custom_widgets = etree.find("customwidgets") + + if custom_widgets is None: + return + + for custom_widget in custom_widgets: + class_name = custom_widget.find("class").text + header = custom_widget.find("header").text + module = importlib.import_module(headerToModule(header)) + self.custom_widgets[class_name] = getattr(module, + class_name) + + def load(self, uifile, *args, **kwargs): + from xml.etree.ElementTree import ElementTree + + # For whatever reason, if this doesn't happen then + # reading an invalid or non-existing .ui file throws + # a RuntimeError. + etree = ElementTree() + etree.parse(uifile) + self._loadCustomWidgets(etree) + + widget = Qt._QtUiTools.QUiLoader.load( + self, uifile, *args, **kwargs) + + # Workaround for PySide 1.0.9, see issue #208 + widget.parentWidget() + + return widget + + def createWidget(self, class_name, parent=None, name=""): + """Called for each widget defined in ui file + + Overridden here to populate `baseinstance` instead. + + """ + + if parent is None and self.baseinstance: + # Supposed to create the top-level widget, + # return the base instance instead + return self.baseinstance + + # For some reason, Line is not in the list of available + # widgets, but works fine, so we have to special case it here. + if class_name in self.availableWidgets() + ["Line"]: + # Create a new widget for child widgets + widget = Qt._QtUiTools.QUiLoader.createWidget(self, + class_name, + parent, + name) + elif class_name in self.custom_widgets: + widget = self.custom_widgets[class_name](parent) + else: + raise Exception("Custom widget '%s' not supported" + % class_name) + + if self.baseinstance: + # Set an attribute for the new child widget on the base + # instance, just like PyQt5.uic.loadUi does. + setattr(self.baseinstance, name, widget) + + return widget + + widget = _UiLoader(baseinstance).load(uifile) + Qt.QtCore.QMetaObject.connectSlotsByName(widget) + + return widget + + else: + raise NotImplementedError("No implementation available for loadUi") + + +"""Misplaced members + +These members from the original submodule are misplaced relative PySide2 + +""" +_misplaced_members = { + "PySide2": { + "QtCore.QStringListModel": "QtCore.QStringListModel", + "QtGui.QStringListModel": "QtCore.QStringListModel", + "QtCore.Property": "QtCore.Property", + "QtCore.Signal": "QtCore.Signal", + "QtCore.Slot": "QtCore.Slot", + "QtCore.QAbstractProxyModel": "QtCore.QAbstractProxyModel", + "QtCore.QSortFilterProxyModel": "QtCore.QSortFilterProxyModel", + "QtCore.QItemSelection": "QtCore.QItemSelection", + "QtCore.QItemSelectionModel": "QtCore.QItemSelectionModel", + "QtCore.QItemSelectionRange": "QtCore.QItemSelectionRange", + "QtUiTools.QUiLoader": ["QtCompat.loadUi", _loadUi], + "shiboken2.wrapInstance": ["QtCompat.wrapInstance", _wrapinstance], + "shiboken2.getCppPointer": ["QtCompat.getCppPointer", _getcpppointer], + "shiboken2.isValid": ["QtCompat.isValid", _isvalid], + "QtWidgets.qApp": "QtWidgets.QApplication.instance()", + "QtCore.QCoreApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtWidgets.QApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtCore.qInstallMessageHandler": [ + "QtCompat.qInstallMessageHandler", _qInstallMessageHandler + ], + "QtWidgets.QStyleOptionViewItem": "QtCompat.QStyleOptionViewItemV4", + }, + "PyQt5": { + "QtCore.pyqtProperty": "QtCore.Property", + "QtCore.pyqtSignal": "QtCore.Signal", + "QtCore.pyqtSlot": "QtCore.Slot", + "QtCore.QAbstractProxyModel": "QtCore.QAbstractProxyModel", + "QtCore.QSortFilterProxyModel": "QtCore.QSortFilterProxyModel", + "QtCore.QStringListModel": "QtCore.QStringListModel", + "QtCore.QItemSelection": "QtCore.QItemSelection", + "QtCore.QItemSelectionModel": "QtCore.QItemSelectionModel", + "QtCore.QItemSelectionRange": "QtCore.QItemSelectionRange", + "uic.loadUi": ["QtCompat.loadUi", _loadUi], + "sip.wrapinstance": ["QtCompat.wrapInstance", _wrapinstance], + "sip.unwrapinstance": ["QtCompat.getCppPointer", _getcpppointer], + "sip.isdeleted": ["QtCompat.isValid", _isvalid], + "QtWidgets.qApp": "QtWidgets.QApplication.instance()", + "QtCore.QCoreApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtWidgets.QApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtCore.qInstallMessageHandler": [ + "QtCompat.qInstallMessageHandler", _qInstallMessageHandler + ], + "QtWidgets.QStyleOptionViewItem": "QtCompat.QStyleOptionViewItemV4", + }, + "PySide": { + "QtGui.QAbstractProxyModel": "QtCore.QAbstractProxyModel", + "QtGui.QSortFilterProxyModel": "QtCore.QSortFilterProxyModel", + "QtGui.QStringListModel": "QtCore.QStringListModel", + "QtGui.QItemSelection": "QtCore.QItemSelection", + "QtGui.QItemSelectionModel": "QtCore.QItemSelectionModel", + "QtCore.Property": "QtCore.Property", + "QtCore.Signal": "QtCore.Signal", + "QtCore.Slot": "QtCore.Slot", + "QtGui.QItemSelectionRange": "QtCore.QItemSelectionRange", + "QtGui.QAbstractPrintDialog": "QtPrintSupport.QAbstractPrintDialog", + "QtGui.QPageSetupDialog": "QtPrintSupport.QPageSetupDialog", + "QtGui.QPrintDialog": "QtPrintSupport.QPrintDialog", + "QtGui.QPrintEngine": "QtPrintSupport.QPrintEngine", + "QtGui.QPrintPreviewDialog": "QtPrintSupport.QPrintPreviewDialog", + "QtGui.QPrintPreviewWidget": "QtPrintSupport.QPrintPreviewWidget", + "QtGui.QPrinter": "QtPrintSupport.QPrinter", + "QtGui.QPrinterInfo": "QtPrintSupport.QPrinterInfo", + "QtUiTools.QUiLoader": ["QtCompat.loadUi", _loadUi], + "shiboken.wrapInstance": ["QtCompat.wrapInstance", _wrapinstance], + "shiboken.unwrapInstance": ["QtCompat.getCppPointer", _getcpppointer], + "shiboken.isValid": ["QtCompat.isValid", _isvalid], + "QtGui.qApp": "QtWidgets.QApplication.instance()", + "QtCore.QCoreApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtGui.QApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtCore.qInstallMsgHandler": [ + "QtCompat.qInstallMessageHandler", _qInstallMessageHandler + ], + "QtGui.QStyleOptionViewItemV4": "QtCompat.QStyleOptionViewItemV4", + }, + "PyQt4": { + "QtGui.QAbstractProxyModel": "QtCore.QAbstractProxyModel", + "QtGui.QSortFilterProxyModel": "QtCore.QSortFilterProxyModel", + "QtGui.QItemSelection": "QtCore.QItemSelection", + "QtGui.QStringListModel": "QtCore.QStringListModel", + "QtGui.QItemSelectionModel": "QtCore.QItemSelectionModel", + "QtCore.pyqtProperty": "QtCore.Property", + "QtCore.pyqtSignal": "QtCore.Signal", + "QtCore.pyqtSlot": "QtCore.Slot", + "QtGui.QItemSelectionRange": "QtCore.QItemSelectionRange", + "QtGui.QAbstractPrintDialog": "QtPrintSupport.QAbstractPrintDialog", + "QtGui.QPageSetupDialog": "QtPrintSupport.QPageSetupDialog", + "QtGui.QPrintDialog": "QtPrintSupport.QPrintDialog", + "QtGui.QPrintEngine": "QtPrintSupport.QPrintEngine", + "QtGui.QPrintPreviewDialog": "QtPrintSupport.QPrintPreviewDialog", + "QtGui.QPrintPreviewWidget": "QtPrintSupport.QPrintPreviewWidget", + "QtGui.QPrinter": "QtPrintSupport.QPrinter", + "QtGui.QPrinterInfo": "QtPrintSupport.QPrinterInfo", + # "QtCore.pyqtSignature": "QtCore.Slot", + "uic.loadUi": ["QtCompat.loadUi", _loadUi], + "sip.wrapinstance": ["QtCompat.wrapInstance", _wrapinstance], + "sip.unwrapinstance": ["QtCompat.getCppPointer", _getcpppointer], + "sip.isdeleted": ["QtCompat.isValid", _isvalid], + "QtCore.QString": "str", + "QtGui.qApp": "QtWidgets.QApplication.instance()", + "QtCore.QCoreApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtGui.QApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtCore.qInstallMsgHandler": [ + "QtCompat.qInstallMessageHandler", _qInstallMessageHandler + ], + "QtGui.QStyleOptionViewItemV4": "QtCompat.QStyleOptionViewItemV4", + } +} + +""" Compatibility Members + +This dictionary is used to build Qt.QtCompat objects that provide a consistent +interface for obsolete members, and differences in binding return values. + +{ + "binding": { + "classname": { + "targetname": "binding_namespace", + } + } +} +""" +_compatibility_members = { + "PySide2": { + "QWidget": { + "grab": "QtWidgets.QWidget.grab", + }, + "QHeaderView": { + "sectionsClickable": "QtWidgets.QHeaderView.sectionsClickable", + "setSectionsClickable": + "QtWidgets.QHeaderView.setSectionsClickable", + "sectionResizeMode": "QtWidgets.QHeaderView.sectionResizeMode", + "setSectionResizeMode": + "QtWidgets.QHeaderView.setSectionResizeMode", + "sectionsMovable": "QtWidgets.QHeaderView.sectionsMovable", + "setSectionsMovable": "QtWidgets.QHeaderView.setSectionsMovable", + }, + "QFileDialog": { + "getOpenFileName": "QtWidgets.QFileDialog.getOpenFileName", + "getOpenFileNames": "QtWidgets.QFileDialog.getOpenFileNames", + "getSaveFileName": "QtWidgets.QFileDialog.getSaveFileName", + }, + }, + "PyQt5": { + "QWidget": { + "grab": "QtWidgets.QWidget.grab", + }, + "QHeaderView": { + "sectionsClickable": "QtWidgets.QHeaderView.sectionsClickable", + "setSectionsClickable": + "QtWidgets.QHeaderView.setSectionsClickable", + "sectionResizeMode": "QtWidgets.QHeaderView.sectionResizeMode", + "setSectionResizeMode": + "QtWidgets.QHeaderView.setSectionResizeMode", + "sectionsMovable": "QtWidgets.QHeaderView.sectionsMovable", + "setSectionsMovable": "QtWidgets.QHeaderView.setSectionsMovable", + }, + "QFileDialog": { + "getOpenFileName": "QtWidgets.QFileDialog.getOpenFileName", + "getOpenFileNames": "QtWidgets.QFileDialog.getOpenFileNames", + "getSaveFileName": "QtWidgets.QFileDialog.getSaveFileName", + }, + }, + "PySide": { + "QWidget": { + "grab": "QtWidgets.QPixmap.grabWidget", + }, + "QHeaderView": { + "sectionsClickable": "QtWidgets.QHeaderView.isClickable", + "setSectionsClickable": "QtWidgets.QHeaderView.setClickable", + "sectionResizeMode": "QtWidgets.QHeaderView.resizeMode", + "setSectionResizeMode": "QtWidgets.QHeaderView.setResizeMode", + "sectionsMovable": "QtWidgets.QHeaderView.isMovable", + "setSectionsMovable": "QtWidgets.QHeaderView.setMovable", + }, + "QFileDialog": { + "getOpenFileName": "QtWidgets.QFileDialog.getOpenFileName", + "getOpenFileNames": "QtWidgets.QFileDialog.getOpenFileNames", + "getSaveFileName": "QtWidgets.QFileDialog.getSaveFileName", + }, + }, + "PyQt4": { + "QWidget": { + "grab": "QtWidgets.QPixmap.grabWidget", + }, + "QHeaderView": { + "sectionsClickable": "QtWidgets.QHeaderView.isClickable", + "setSectionsClickable": "QtWidgets.QHeaderView.setClickable", + "sectionResizeMode": "QtWidgets.QHeaderView.resizeMode", + "setSectionResizeMode": "QtWidgets.QHeaderView.setResizeMode", + "sectionsMovable": "QtWidgets.QHeaderView.isMovable", + "setSectionsMovable": "QtWidgets.QHeaderView.setMovable", + }, + "QFileDialog": { + "getOpenFileName": "QtWidgets.QFileDialog.getOpenFileName", + "getOpenFileNames": "QtWidgets.QFileDialog.getOpenFileNames", + "getSaveFileName": "QtWidgets.QFileDialog.getSaveFileName", + }, + }, +} + + +def _apply_site_config(): + try: + import QtSiteConfig + except ImportError: + # If no QtSiteConfig module found, no modifications + # to _common_members are needed. + pass + else: + # Provide the ability to modify the dicts used to build Qt.py + if hasattr(QtSiteConfig, 'update_members'): + QtSiteConfig.update_members(_common_members) + + if hasattr(QtSiteConfig, 'update_misplaced_members'): + QtSiteConfig.update_misplaced_members(members=_misplaced_members) + + if hasattr(QtSiteConfig, 'update_compatibility_members'): + QtSiteConfig.update_compatibility_members( + members=_compatibility_members) + + +def _new_module(name): + return types.ModuleType(__name__ + "." + name) + + +def _import_sub_module(module, name): + """import_sub_module will mimic the function of importlib.import_module""" + module = __import__(module.__name__ + "." + name) + for level in name.split("."): + module = getattr(module, level) + return module + + +def _setup(module, extras): + """Install common submodules""" + + Qt.__binding__ = module.__name__ + + for name in list(_common_members) + extras: + try: + submodule = _import_sub_module( + module, name) + except ImportError: + try: + # For extra modules like sip and shiboken that may not be + # children of the binding. + submodule = __import__(name) + except ImportError: + continue + + setattr(Qt, "_" + name, submodule) + + if name not in extras: + # Store reference to original binding, + # but don't store speciality modules + # such as uic or QtUiTools + setattr(Qt, name, _new_module(name)) + + +def _reassign_misplaced_members(binding): + """Apply misplaced members from `binding` to Qt.py + + Arguments: + binding (dict): Misplaced members + + """ + + for src, dst in _misplaced_members[binding].items(): + dst_value = None + + src_parts = src.split(".") + src_module = src_parts[0] + src_member = None + if len(src_parts) > 1: + src_member = src_parts[1:] + + if isinstance(dst, (list, tuple)): + dst, dst_value = dst + + dst_parts = dst.split(".") + dst_module = dst_parts[0] + dst_member = None + if len(dst_parts) > 1: + dst_member = dst_parts[1] + + # Get the member we want to store in the namesapce. + if not dst_value: + try: + _part = getattr(Qt, "_" + src_module) + while src_member: + member = src_member.pop(0) + _part = getattr(_part, member) + dst_value = _part + except AttributeError: + # If the member we want to store in the namespace does not + # exist, there is no need to continue. This can happen if a + # request was made to rename a member that didn't exist, for + # example if QtWidgets isn't available on the target platform. + _log("Misplaced member has no source: {0}".format(src)) + continue + + try: + src_object = getattr(Qt, dst_module) + except AttributeError: + if dst_module not in _common_members: + # Only create the Qt parent module if its listed in + # _common_members. Without this check, if you remove QtCore + # from _common_members, the default _misplaced_members will add + # Qt.QtCore so it can add Signal, Slot, etc. + msg = 'Not creating missing member module "{m}" for "{c}"' + _log(msg.format(m=dst_module, c=dst_member)) + continue + # If the dst is valid but the Qt parent module does not exist + # then go ahead and create a new module to contain the member. + setattr(Qt, dst_module, _new_module(dst_module)) + src_object = getattr(Qt, dst_module) + # Enable direct import of the new module + sys.modules[__name__ + "." + dst_module] = src_object + + if not dst_value: + dst_value = getattr(Qt, "_" + src_module) + if src_member: + dst_value = getattr(dst_value, src_member) + + setattr( + src_object, + dst_member or dst_module, + dst_value + ) + + +def _build_compatibility_members(binding, decorators=None): + """Apply `binding` to QtCompat + + Arguments: + binding (str): Top level binding in _compatibility_members. + decorators (dict, optional): Provides the ability to decorate the + original Qt methods when needed by a binding. This can be used + to change the returned value to a standard value. The key should + be the classname, the value is a dict where the keys are the + target method names, and the values are the decorator functions. + + """ + + decorators = decorators or dict() + + # Allow optional site-level customization of the compatibility members. + # This method does not need to be implemented in QtSiteConfig. + try: + import QtSiteConfig + except ImportError: + pass + else: + if hasattr(QtSiteConfig, 'update_compatibility_decorators'): + QtSiteConfig.update_compatibility_decorators(binding, decorators) + + _QtCompat = type("QtCompat", (object,), {}) + + for classname, bindings in _compatibility_members[binding].items(): + attrs = {} + for target, binding in bindings.items(): + namespaces = binding.split('.') + try: + src_object = getattr(Qt, "_" + namespaces[0]) + except AttributeError as e: + _log("QtCompat: AttributeError: %s" % e) + # Skip reassignment of non-existing members. + # This can happen if a request was made to + # rename a member that didn't exist, for example + # if QtWidgets isn't available on the target platform. + continue + + # Walk down any remaining namespace getting the object assuming + # that if the first namespace exists the rest will exist. + for namespace in namespaces[1:]: + src_object = getattr(src_object, namespace) + + # decorate the Qt method if a decorator was provided. + if target in decorators.get(classname, []): + # staticmethod must be called on the decorated method to + # prevent a TypeError being raised when the decorated method + # is called. + src_object = staticmethod( + decorators[classname][target](src_object)) + + attrs[target] = src_object + + # Create the QtCompat class and install it into the namespace + compat_class = type(classname, (_QtCompat,), attrs) + setattr(Qt.QtCompat, classname, compat_class) + + +def _pyside2(): + """Initialise PySide2 + + These functions serve to test the existence of a binding + along with set it up in such a way that it aligns with + the final step; adding members from the original binding + to Qt.py + + """ + + import PySide2 as module + extras = ["QtUiTools"] + try: + try: + # Before merge of PySide and shiboken + import shiboken2 + except ImportError: + # After merge of PySide and shiboken, May 2017 + from PySide2 import shiboken2 + extras.append("shiboken2") + except ImportError: + pass + + _setup(module, extras) + Qt.__binding_version__ = module.__version__ + + if hasattr(Qt, "_shiboken2"): + Qt.QtCompat.wrapInstance = _wrapinstance + Qt.QtCompat.getCppPointer = _getcpppointer + Qt.QtCompat.delete = shiboken2.delete + + if hasattr(Qt, "_QtUiTools"): + Qt.QtCompat.loadUi = _loadUi + + if hasattr(Qt, "_QtCore"): + Qt.__qt_version__ = Qt._QtCore.qVersion() + Qt.QtCompat.dataChanged = ( + lambda self, topleft, bottomright, roles=None: + self.dataChanged.emit(topleft, bottomright, roles or []) + ) + + if hasattr(Qt, "_QtWidgets"): + Qt.QtCompat.setSectionResizeMode = \ + Qt._QtWidgets.QHeaderView.setSectionResizeMode + + _reassign_misplaced_members("PySide2") + _build_compatibility_members("PySide2") + + +def _pyside(): + """Initialise PySide""" + + import PySide as module + extras = ["QtUiTools"] + try: + try: + # Before merge of PySide and shiboken + import shiboken + except ImportError: + # After merge of PySide and shiboken, May 2017 + from PySide import shiboken + extras.append("shiboken") + except ImportError: + pass + + _setup(module, extras) + Qt.__binding_version__ = module.__version__ + + if hasattr(Qt, "_shiboken"): + Qt.QtCompat.wrapInstance = _wrapinstance + Qt.QtCompat.getCppPointer = _getcpppointer + Qt.QtCompat.delete = shiboken.delete + + if hasattr(Qt, "_QtUiTools"): + Qt.QtCompat.loadUi = _loadUi + + if hasattr(Qt, "_QtGui"): + setattr(Qt, "QtWidgets", _new_module("QtWidgets")) + setattr(Qt, "_QtWidgets", Qt._QtGui) + if hasattr(Qt._QtGui, "QX11Info"): + setattr(Qt, "QtX11Extras", _new_module("QtX11Extras")) + Qt.QtX11Extras.QX11Info = Qt._QtGui.QX11Info + + Qt.QtCompat.setSectionResizeMode = Qt._QtGui.QHeaderView.setResizeMode + + if hasattr(Qt, "_QtCore"): + Qt.__qt_version__ = Qt._QtCore.qVersion() + Qt.QtCompat.dataChanged = ( + lambda self, topleft, bottomright, roles=None: + self.dataChanged.emit(topleft, bottomright) + ) + + _reassign_misplaced_members("PySide") + _build_compatibility_members("PySide") + + +def _pyqt5(): + """Initialise PyQt5""" + + import PyQt5 as module + extras = ["uic"] + + try: + import sip + extras += ["sip"] + except ImportError: + + # Relevant to PyQt5 5.11 and above + try: + from PyQt5 import sip + extras += ["sip"] + except ImportError: + sip = None + + _setup(module, extras) + if hasattr(Qt, "_sip"): + Qt.QtCompat.wrapInstance = _wrapinstance + Qt.QtCompat.getCppPointer = _getcpppointer + Qt.QtCompat.delete = sip.delete + + if hasattr(Qt, "_uic"): + Qt.QtCompat.loadUi = _loadUi + + if hasattr(Qt, "_QtCore"): + Qt.__binding_version__ = Qt._QtCore.PYQT_VERSION_STR + Qt.__qt_version__ = Qt._QtCore.QT_VERSION_STR + Qt.QtCompat.dataChanged = ( + lambda self, topleft, bottomright, roles=None: + self.dataChanged.emit(topleft, bottomright, roles or []) + ) + + if hasattr(Qt, "_QtWidgets"): + Qt.QtCompat.setSectionResizeMode = \ + Qt._QtWidgets.QHeaderView.setSectionResizeMode + + _reassign_misplaced_members("PyQt5") + _build_compatibility_members('PyQt5') + + +def _pyqt4(): + """Initialise PyQt4""" + + import sip + + # Validation of envivornment variable. Prevents an error if + # the variable is invalid since it's just a hint. + try: + hint = int(QT_SIP_API_HINT) + except TypeError: + hint = None # Variable was None, i.e. not set. + except ValueError: + raise ImportError("QT_SIP_API_HINT=%s must be a 1 or 2") + + for api in ("QString", + "QVariant", + "QDate", + "QDateTime", + "QTextStream", + "QTime", + "QUrl"): + try: + sip.setapi(api, hint or 2) + except AttributeError: + raise ImportError("PyQt4 < 4.6 isn't supported by Qt.py") + except ValueError: + actual = sip.getapi(api) + if not hint: + raise ImportError("API version already set to %d" % actual) + else: + # Having provided a hint indicates a soft constraint, one + # that doesn't throw an exception. + sys.stderr.write( + "Warning: API '%s' has already been set to %d.\n" + % (api, actual) + ) + + import PyQt4 as module + extras = ["uic"] + try: + import sip + extras.append(sip.__name__) + except ImportError: + sip = None + + _setup(module, extras) + if hasattr(Qt, "_sip"): + Qt.QtCompat.wrapInstance = _wrapinstance + Qt.QtCompat.getCppPointer = _getcpppointer + Qt.QtCompat.delete = sip.delete + + if hasattr(Qt, "_uic"): + Qt.QtCompat.loadUi = _loadUi + + if hasattr(Qt, "_QtGui"): + setattr(Qt, "QtWidgets", _new_module("QtWidgets")) + setattr(Qt, "_QtWidgets", Qt._QtGui) + if hasattr(Qt._QtGui, "QX11Info"): + setattr(Qt, "QtX11Extras", _new_module("QtX11Extras")) + Qt.QtX11Extras.QX11Info = Qt._QtGui.QX11Info + + Qt.QtCompat.setSectionResizeMode = \ + Qt._QtGui.QHeaderView.setResizeMode + + if hasattr(Qt, "_QtCore"): + Qt.__binding_version__ = Qt._QtCore.PYQT_VERSION_STR + Qt.__qt_version__ = Qt._QtCore.QT_VERSION_STR + Qt.QtCompat.dataChanged = ( + lambda self, topleft, bottomright, roles=None: + self.dataChanged.emit(topleft, bottomright) + ) + + _reassign_misplaced_members("PyQt4") + + # QFileDialog QtCompat decorator + def _standardizeQFileDialog(some_function): + """Decorator that makes PyQt4 return conform to other bindings""" + def wrapper(*args, **kwargs): + ret = (some_function(*args, **kwargs)) + + # PyQt4 only returns the selected filename, force it to a + # standard return of the selected filename, and a empty string + # for the selected filter + return ret, '' + + wrapper.__doc__ = some_function.__doc__ + wrapper.__name__ = some_function.__name__ + + return wrapper + + decorators = { + "QFileDialog": { + "getOpenFileName": _standardizeQFileDialog, + "getOpenFileNames": _standardizeQFileDialog, + "getSaveFileName": _standardizeQFileDialog, + } + } + _build_compatibility_members('PyQt4', decorators) + + +def _none(): + """Internal option (used in installer)""" + + Mock = type("Mock", (), {"__getattr__": lambda Qt, attr: None}) + + Qt.__binding__ = "None" + Qt.__qt_version__ = "0.0.0" + Qt.__binding_version__ = "0.0.0" + Qt.QtCompat.loadUi = lambda uifile, baseinstance=None: None + Qt.QtCompat.setSectionResizeMode = lambda *args, **kwargs: None + + for submodule in _common_members.keys(): + setattr(Qt, submodule, Mock()) + setattr(Qt, "_" + submodule, Mock()) + + +def _log(text): + if QT_VERBOSE: + sys.stdout.write(text + "\n") + + +def _convert(lines): + """Convert compiled .ui file from PySide2 to Qt.py + + Arguments: + lines (list): Each line of of .ui file + + Usage: + >> with open("myui.py") as f: + .. lines = _convert(f.readlines()) + + """ + + def parse(line): + line = line.replace("from PySide2 import", "from Qt import QtCompat,") + line = line.replace("QtWidgets.QApplication.translate", + "QtCompat.translate") + if "QtCore.SIGNAL" in line: + raise NotImplementedError("QtCore.SIGNAL is missing from PyQt5 " + "and so Qt.py does not support it: you " + "should avoid defining signals inside " + "your ui files.") + return line + + parsed = list() + for line in lines: + line = parse(line) + parsed.append(line) + + return parsed + + +def _cli(args): + """Qt.py command-line interface""" + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("--convert", + help="Path to compiled Python module, e.g. my_ui.py") + parser.add_argument("--compile", + help="Accept raw .ui file and compile with native " + "PySide2 compiler.") + parser.add_argument("--stdout", + help="Write to stdout instead of file", + action="store_true") + parser.add_argument("--stdin", + help="Read from stdin instead of file", + action="store_true") + + args = parser.parse_args(args) + + if args.stdout: + raise NotImplementedError("--stdout") + + if args.stdin: + raise NotImplementedError("--stdin") + + if args.compile: + raise NotImplementedError("--compile") + + if args.convert: + sys.stdout.write("#\n" + "# WARNING: --convert is an ALPHA feature.\n#\n" + "# See https://github.com/mottosso/Qt.py/pull/132\n" + "# for details.\n" + "#\n") + + # + # ------> Read + # + with open(args.convert) as f: + lines = _convert(f.readlines()) + + backup = "%s_backup%s" % os.path.splitext(args.convert) + sys.stdout.write("Creating \"%s\"..\n" % backup) + shutil.copy(args.convert, backup) + + # + # <------ Write + # + with open(args.convert, "w") as f: + f.write("".join(lines)) + + sys.stdout.write("Successfully converted \"%s\"\n" % args.convert) + + +class MissingMember(object): + """ + A placeholder type for a missing Qt object not + included in Qt.py + + Args: + name (str): The name of the missing type + details (str): An optional custom error message + """ + ERR_TMPL = ("{} is not a common object across PySide2 " + "and the other Qt bindings. It is not included " + "as a common member in the Qt.py layer") + + def __init__(self, name, details=''): + self.__name = name + self.__err = self.ERR_TMPL.format(name) + + if details: + self.__err = "{}: {}".format(self.__err, details) + + def __repr__(self): + return "<{}: {}>".format(self.__class__.__name__, self.__name) + + def __getattr__(self, name): + raise NotImplementedError(self.__err) + + def __call__(self, *a, **kw): + raise NotImplementedError(self.__err) + + +def _install(): + # Default order (customise order and content via QT_PREFERRED_BINDING) + default_order = ("PySide2", "PyQt5", "PySide", "PyQt4") + preferred_order = list( + b for b in QT_PREFERRED_BINDING.split(os.pathsep) if b + ) + + order = preferred_order or default_order + + available = { + "PySide2": _pyside2, + "PyQt5": _pyqt5, + "PySide": _pyside, + "PyQt4": _pyqt4, + "None": _none + } + + _log("Order: '%s'" % "', '".join(order)) + + # Allow site-level customization of the available modules. + _apply_site_config() + + found_binding = False + for name in order: + _log("Trying %s" % name) + + try: + available[name]() + found_binding = True + break + + except ImportError as e: + _log("ImportError: %s" % e) + + except KeyError: + _log("ImportError: Preferred binding '%s' not found." % name) + + if not found_binding: + # If not binding were found, throw this error + raise ImportError("No Qt binding were found.") + + # Install individual members + for name, members in _common_members.items(): + try: + their_submodule = getattr(Qt, "_%s" % name) + except AttributeError: + continue + + our_submodule = getattr(Qt, name) + + # Enable import * + __all__.append(name) + + # Enable direct import of submodule, + # e.g. import Qt.QtCore + sys.modules[__name__ + "." + name] = our_submodule + + for member in members: + # Accept that a submodule may miss certain members. + try: + their_member = getattr(their_submodule, member) + except AttributeError: + _log("'%s.%s' was missing." % (name, member)) + continue + + setattr(our_submodule, member, their_member) + + # Install missing member placeholders + for name, members in _missing_members.items(): + our_submodule = getattr(Qt, name) + + for member in members: + + # If the submodule already has this member installed, + # either by the common members, or the site config, + # then skip installing this one over it. + if hasattr(our_submodule, member): + continue + + placeholder = MissingMember("{}.{}".format(name, member), + details=members[member]) + setattr(our_submodule, member, placeholder) + + # Enable direct import of QtCompat + sys.modules['Qt.QtCompat'] = Qt.QtCompat + + # Backwards compatibility + if hasattr(Qt.QtCompat, 'loadUi'): + Qt.QtCompat.load_ui = Qt.QtCompat.loadUi + + +_install() + +# Setup Binding Enum states +Qt.IsPySide2 = Qt.__binding__ == 'PySide2' +Qt.IsPyQt5 = Qt.__binding__ == 'PyQt5' +Qt.IsPySide = Qt.__binding__ == 'PySide' +Qt.IsPyQt4 = Qt.__binding__ == 'PyQt4' + +"""Augment QtCompat + +QtCompat contains wrappers and added functionality +to the original bindings, such as the CLI interface +and otherwise incompatible members between bindings, +such as `QHeaderView.setSectionResizeMode`. + +""" + +Qt.QtCompat._cli = _cli +Qt.QtCompat._convert = _convert + +# Enable command-line interface +if __name__ == "__main__": + _cli(sys.argv[1:]) + + +# The MIT License (MIT) +# +# Copyright (c) 2016-2017 Marcus Ottosson +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# In PySide(2), loadUi does not exist, so we implement it +# +# `_UiLoader` is adapted from the qtpy project, which was further influenced +# by qt-helpers which was released under a 3-clause BSD license which in turn +# is based on a solution at: +# +# - https://gist.github.com/cpbotha/1b42a20c8f3eb9bb7cb8 +# +# The License for this code is as follows: +# +# qt-helpers - a common front-end to various Qt modules +# +# Copyright (c) 2015, Chris Beaumont and Thomas Robitaille +# +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the +# distribution. +# * Neither the name of the Glue project nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Which itself was based on the solution at +# +# https://gist.github.com/cpbotha/1b42a20c8f3eb9bb7cb8 +# +# which was released under the MIT license: +# +# Copyright (c) 2011 Sebastian Wiesner +# Modifications by Charl Botha +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files +# (the "Software"),to deal in the Software without restriction, +# including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/openpype/vendor/python/common/scriptsmenu/vendor/__init__.py b/openpype/vendor/python/common/scriptsmenu/vendor/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/vendor/python/common/scriptsmenu/version.py b/openpype/vendor/python/common/scriptsmenu/version.py new file mode 100644 index 0000000000..73f9426c2d --- /dev/null +++ b/openpype/vendor/python/common/scriptsmenu/version.py @@ -0,0 +1,9 @@ +VERSION_MAJOR = 1 +VERSION_MINOR = 5 +VERSION_PATCH = 1 + + +version = '{}.{}.{}'.format(VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) +__version__ = version + +__all__ = ['version', '__version__'] diff --git a/openpype/version.py b/openpype/version.py index 2fc2b4bc26..c4bd5a14cb 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.1" +__version__ = "3.3.0-nightly.9" diff --git a/poetry.lock b/poetry.lock index 30dbe50c19..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 = "68784b7eb5b7bb5f409b61ab31d4403878a3e1b7" +resolved_reference = "55a7c331e6dc5f81639af50ca4a8cc9d73e9273d" [[package]] name = "aiohttp" diff --git a/repos/avalon-core b/repos/avalon-core index d8be0bdb37..e5c8a15fde 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit d8be0bdb37961e32243f1de0eb9696e86acf7443 +Subproject commit e5c8a15fde77708c924eab3018bda255f17b5390 diff --git a/start.py b/start.py index 1b5c25ae3a..6473a926d0 100644 --- a/start.py +++ b/start.py @@ -208,16 +208,27 @@ def set_openpype_global_environments() -> None: """Set global OpenPype's environments.""" import acre - from openpype.settings import get_environments + try: + from openpype.settings import get_general_environments - all_env = get_environments() + general_env = get_general_environments() - # TODO Global environments will be stored in "general" settings so loading - # will be modified and can be done in igniter. - env = acre.merge( - acre.parse(all_env["global"]), + except Exception: + # Backwards compatibility for OpenPype versions where + # `get_general_environments` does not exists yet + from openpype.settings import get_environments + + all_env = get_environments() + general_env = all_env["global"] + + 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 6315b7b27c..9472c75c2f 100644 --- a/tools/create_env.ps1 +++ b/tools/create_env.ps1 @@ -52,15 +52,35 @@ function Show-PSWarning() { function Install-Poetry() { Write-Color -Text ">>> ", "Installing Poetry ... " -Color Green, Gray + $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-Color -Text "!!! ", "Using pyenv but having no local or global version of Python set." -Color Red, Yellow + 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 - + (Invoke-WebRequest -Uri https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py -UseBasicParsing).Content | & $($python) - } function Test-Python() { Write-Color -Text ">>> ", "Detecting host Python ... " -Color Green, Gray -NoNewline - if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) { - Write-Color -Text "!!! ", "Python not detected" -Color Red, Yellow + $python = "python" + if (Get-Command "pyenv" -ErrorAction SilentlyContinue) { + $pyenv_python = & pyenv which python + if (Test-Path -PathType Leaf -Path "$($pyenv_python)") { + $python = $pyenv_python + } + } + if (-not (Get-Command $python -ErrorAction SilentlyContinue)) { + Write-Host "!!! Python not detected" -ForegroundColor red Set-Location -Path $current_dir Exit-WithCode 1 } @@ -69,14 +89,13 @@ import sys print('{0}.{1}'.format(sys.version_info[0], sys.version_info[1])) '@ - $p = & python -c $version_command + $p = & $python -c $version_command $env:PYTHON_VERSION = $p $m = $p -match '(\d+)\.(\d+)' if(-not $m) { - Write-Color -Text "FAILED " -Color Red - Write-Color -Text "!!! ", "Cannot determine version" -Color Red, Yellow - Set-Location -Path $current_dir - Exit-WithCode 1 + Write-Host "!!! Cannot determine version" -ForegroundColor red + Set-Location -Path $current_dir + Exit-WithCode 1 } # We are supporting python 3.7 only if (($matches[1] -lt 3) -or ($matches[2] -lt 7)) { diff --git a/tools/run_mongo.ps1 b/tools/run_mongo.ps1 index 6719e520fe..32f6cfed17 100644 --- a/tools/run_mongo.ps1 +++ b/tools/run_mongo.ps1 @@ -41,22 +41,40 @@ function Exit-WithCode($exitcode) { } -function Find-Mongo { +function Find-Mongo ($preferred_version) { $defaultPath = "C:\Program Files\MongoDB\Server" Write-Host ">>> " -NoNewLine -ForegroundColor Green Write-Host "Detecting MongoDB ... " -NoNewline if (-not (Get-Command "mongod" -ErrorAction SilentlyContinue)) { if(Test-Path "$($defaultPath)\*\bin\mongod.exe" -PathType Leaf) { # we have mongo server installed on standard Windows location - # so we can inject it to the PATH. We'll use latest version available. + # so we can inject it to the PATH. We'll use latest version available, or the one defined by + # $preferred_version. $mongoVersions = Get-ChildItem -Directory 'C:\Program Files\MongoDB\Server' | Sort-Object -Property {$_.Name -as [int]} if(Test-Path "$($mongoVersions[-1])\bin\mongod.exe" -PathType Leaf) { - $env:PATH = "$($env:PATH);$($mongoVersions[-1])\bin\" Write-Host "OK" -ForegroundColor Green + $use_version = $mongoVersions[-1] + foreach ($v in $mongoVersions) { + Write-Host " - found [ " -NoNewline + Write-Host $v -NoNewLine -ForegroundColor Cyan + Write-Host " ]" -NoNewLine + + $version = Split-Path $v -Leaf + + if ($preferred_version -eq $version) { + Write-Host " *" -ForegroundColor Green + $use_version = $v + } else { + Write-Host "" + } + } + + $env:PATH = "$($env:PATH);$($use_version)\bin\" + Write-Host " - auto-added from [ " -NoNewline - Write-Host "$($mongoVersions[-1])\bin\mongod.exe" -NoNewLine -ForegroundColor Cyan + Write-Host "$($use_version)\bin\mongod.exe" -NoNewLine -ForegroundColor Cyan Write-Host " ]" - return "$($mongoVersions[-1])\bin\mongod.exe" + return "$($use_version)\bin\mongod.exe" } else { Write-Host "FAILED " -NoNewLine -ForegroundColor Red Write-Host "MongoDB not detected" -ForegroundColor Yellow @@ -95,7 +113,18 @@ $port = 2707 # path to database $dbpath = (Get-Item $openpype_root).parent.FullName + "\mongo_db_data" -$mongoPath = Find-Mongo -Start-Process -FilePath $mongopath "--dbpath $($dbpath) --port $($port)" -PassThru +$preferred_version = "4.0" +$mongoPath = Find-Mongo $preferred_version +Write-Host ">>> " -NoNewLine -ForegroundColor Green +Write-Host "Using DB path: " -NoNewLine +Write-Host " [ " -NoNewline -ForegroundColor Cyan +Write-Host "$($dbpath)" -NoNewline -ForegroundColor White +Write-Host " ] "-ForegroundColor Cyan +Write-Host ">>> " -NoNewLine -ForegroundColor Green +Write-Host "Port: " -NoNewLine +Write-Host " [ " -NoNewline -ForegroundColor Cyan +Write-Host "$($port)" -NoNewline -ForegroundColor White +Write-Host " ] " -ForegroundColor Cyan +Start-Process -FilePath $mongopath "--dbpath $($dbpath) --port $($port)" -PassThru | Out-Null diff --git a/website/docs/admin_hosts_maya.md b/website/docs/admin_hosts_maya.md index 83c4121be9..05a231c21a 100644 --- a/website/docs/admin_hosts_maya.md +++ b/website/docs/admin_hosts_maya.md @@ -4,11 +4,11 @@ title: Maya sidebar_label: Maya --- -## Maya +## Publish Plugins -### Publish Plugins +### Render Settings Validator -#### Render Settings Validator (`ValidateRenderSettings`) +`ValidateRenderSettings` Render Settings Validator is here to make sure artists will submit renders we correct settings. Some of these settings are needed by OpenPype but some @@ -49,4 +49,74 @@ Arnolds Camera (AA) samples to 6. Note that `aiOptions` is not the name of node but rather its type. For renderers there is usually just one instance of this node type but if that is not so, validator will go through all its instances and check the value there. Node type for **VRay** settings is `VRaySettingsNode`, for **Renderman** -it is `rmanGlobals`, for **Redshift** it is `RedshiftOptions`. \ No newline at end of file +it is `rmanGlobals`, for **Redshift** it is `RedshiftOptions`. + +### Model Name Validator + +`ValidateRenderSettings` + +This validator can enforce specific names for model members. It will check them against **Validation Regex**. +There is special group in that regex - **shader**. If present, it will take that part of the name as shader name +and it will compare it with list of shaders defined either in file name specified in **Material File** or from +database file that is per project and can be directly edited from Maya's *OpenPype Tools > Edit Shader name definitions* when +**Use database shader name definitions** is on. This list defines simply as one shader name per line. + +![Settings example](assets/maya-admin_model_name_validator.png) + +For example - you are using default regex `(.*)_(\d)*_(?P.*)_(GEO)` and you have two shaders defined +in either file or database `foo` and `bar`. + +Object named `SomeCube_0001_foo_GEO` will pass but `SomeCube_GEO` will not and `SomeCube_001_xxx_GEO` will not too. + +#### Top level group name +There is a validation for top level group name too. You can specify whatever regex you'd like to use. Default will +pass everything with `_GRP` suffix. You can use *named capturing groups* to validate against specific data. If you +put `(?P.*)` it will try to match everything captured in that group against current asset name. Likewise you can +use it for **subset** and **project** - `(?P.*)` and `(?P.*)`. + +**Example** + +You are working on asset (shot) `0030_OGC_0190`. You have this regex in **Top level group name**: +```regexp +.*?_(?P.*)_GRP +``` + +When you publish your model with top group named like `foo_GRP` it will fail. But with `foo_0030_OGC_0190_GRP` it will pass. + +:::info About regex +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) + +:::note Work in progress +This is still work in progress. Menu definition will be handled more friendly with widgets and not +raw json. +::: + +## 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/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_model_name_validator.png b/website/docs/assets/maya-admin_model_name_validator.png new file mode 100644 index 0000000000..d1b92c5fc3 Binary files /dev/null and b/website/docs/assets/maya-admin_model_name_validator.png differ diff --git a/website/docs/assets/maya-admin_scriptsmenu.png b/website/docs/assets/maya-admin_scriptsmenu.png new file mode 100644 index 0000000000..ecfe7e42a7 Binary files /dev/null and b/website/docs/assets/maya-admin_scriptsmenu.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/docs/project_settings/assets/standalone_creators.png b/website/docs/project_settings/assets/standalone_creators.png new file mode 100644 index 0000000000..cfadfa305d Binary files /dev/null and b/website/docs/project_settings/assets/standalone_creators.png differ diff --git a/website/docs/project_settings/settings_project_standalone.md b/website/docs/project_settings/settings_project_standalone.md new file mode 100644 index 0000000000..b359dc70d0 --- /dev/null +++ b/website/docs/project_settings/settings_project_standalone.md @@ -0,0 +1,98 @@ +--- +id: settings_project_standalone +title: Project Standalone Publisher Setting +sidebar_label: Standalone Publisher +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Project settings can have project specific values. Each new project is using studio values defined in **default** project but these values can be modified or overriden per project. + +:::warning Default studio values +Projects always use default project values unless they have [project override](../admin_settings#project-overrides) (orage colour). Any changes in default project may affect all existing projects. +::: + +## Creator Plugins + +Contains list of implemented families to show in middle menu in Standalone Publisher. Each plugin must contain: +- name +- label +- family +- icon +- default subset(s) +- help (additional short information about family) + +![example of creator plugin](assets/standalone_creators.png) + +## Publish plugins + +### Collect Textures + +Serves to collect all needed information about workfiles and textures created from those. Allows to publish +main workfile (for example from Mari), additional worfiles (from Substance Painter) and exported textures. + +Available configuration: +- Main workfile extension - only single workfile can be "main" one +- Support workfile extensions - additional workfiles will be published to same folder as "main", just under `resourses` subfolder +- Texture extension - what kind of formats are expected for textures +- Additional families for workfile - should any family ('ftrack', 'review') be added to published workfile +- Additional families for textures - should any family ('ftrack', 'review') be added to published textures + +#### Naming conventions + +Implementation tries to be flexible and cover multiple naming conventions for workfiles and textures. + +##### Workfile naming pattern + +Provide regex matching pattern containing regex groups used to parse workfile name to learn needed information. (For example +build name.) + +Example: + +- pattern: ```^([^.]+)(_[^_.]*)?_v([0-9]{3,}).+``` +- with groups: ```["asset", "filler", "version"]``` + +parses `corridorMain_v001` into three groups: +- asset build (`corridorMain`) +- filler (in this case empty) +- version (`001`) + +Advanced example (for texture files): + +- pattern: ```^([^_.]+)_([^_.]+)_v([0-9]{3,})_([^_.]+)_({color_space})_(1[0-9]{3}).+``` +- with groups: ```["asset", "shader", "version", "channel", "color_space", "udim"]``` + +parses `corridorMain_aluminiumID_v001_baseColor_linsRGB_1001.exr`: +- asset build (`corridorMain`) +- shader (`aluminiumID`) +- version (`001`) +- channel (`baseColor`) +- color_space (`linsRGB`) +- udim (`1001`) + + +In case of different naming pattern, additional groups could be added or removed. Number of matching groups (`(...)`) must be same as number of items in `Group order for regex patterns` + +##### Workfile group positions + +For each matching regex group set in previous paragraph, its ordinal position is required (in case of need for addition of new groups etc.) + +Number of groups added here must match number of parsing groups from `Workfile naming pattern`. + +##### Output names + +Output names of published workfiles and textures could be configured separately: +- Subset name template for workfile +- Subset name template for textures (implemented keys: ["color_space", "channel", "subset", "shader"]) + + +### Validate Scene Settings + +#### Check Frame Range for Extensions + +Configure families, file extension and task to validate that DB setting (frame range) matches currently published values. + +### ExtractThumbnailSP + +Plugin responsible for generating thumbnails, configure appropriate values for your version o ffmpeg. \ No newline at end of file diff --git a/website/sidebars.js b/website/sidebars.js index d38973e40f..488814a385 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -65,7 +65,8 @@ module.exports = { label: "Project Settings", items: [ "project_settings/settings_project_global", - "project_settings/settings_project_nuke" + "project_settings/settings_project_nuke", + "project_settings/settings_project_standalone" ], }, ], 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 + + + + + + + + + + + + + + +