diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000000..20ae298f70
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,16 @@
+## Brief description
+First sentence is brief description.
+
+## Description
+Next paragraf is more elaborate text with more info. This will be displayed for example in collapsed form under the first sentence in a changelog.
+
+## Additional info
+The rest will be ignored in changelog and should contain any additional
+technical information.
+
+## Documentation (add _"type: documentation"_ label)
+[feature_documentation](future_url_after_it_will_be_merged)
+
+## Testing notes:
+1. start with this step
+2. follow this step
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9405ff759..0c6d1b8fe1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,48 +1,63 @@
# Changelog
-## [3.7.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD)
+## [3.7.0-nightly.10](https://github.com/pypeclub/OpenPype/tree/HEAD)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.4...HEAD)
-### π Documentation
+**Deprecated:**
-- docs\[website\]: Add Ellipse Studio \(logo\) as an OpenPype contributor [\#2324](https://github.com/pypeclub/OpenPype/pull/2324)
-
-**π New features**
-
-- Store typed version dependencies for workfiles [\#2192](https://github.com/pypeclub/OpenPype/pull/2192)
+- General: Default modules hierarchy n2 [\#2368](https://github.com/pypeclub/OpenPype/pull/2368)
**π Enhancements**
-- Hiero: Add experimental tools action [\#2323](https://github.com/pypeclub/OpenPype/pull/2323)
-- Input links: Cleanup and unification of differences [\#2322](https://github.com/pypeclub/OpenPype/pull/2322)
-- General: Run process log stderr as info log level [\#2309](https://github.com/pypeclub/OpenPype/pull/2309)
-- Tools: Cleanup of unused classes [\#2304](https://github.com/pypeclub/OpenPype/pull/2304)
-- Project Manager: Added ability to delete project [\#2298](https://github.com/pypeclub/OpenPype/pull/2298)
-- Ftrack: Synchronize input links [\#2287](https://github.com/pypeclub/OpenPype/pull/2287)
-- StandalonePublisher: Remove unused plugin ExtractHarmonyZip [\#2277](https://github.com/pypeclub/OpenPype/pull/2277)
-- Ftrack: Support multiple reviews [\#2271](https://github.com/pypeclub/OpenPype/pull/2271)
-- Ftrack: Remove unused clean component plugin [\#2269](https://github.com/pypeclub/OpenPype/pull/2269)
-- Houdini: Add experimental tools action [\#2267](https://github.com/pypeclub/OpenPype/pull/2267)
-- Tools: Assets widget [\#2265](https://github.com/pypeclub/OpenPype/pull/2265)
-- Nuke: extract baked review videos presets [\#2248](https://github.com/pypeclub/OpenPype/pull/2248)
-- TVPaint: Workers rendering [\#2209](https://github.com/pypeclub/OpenPype/pull/2209)
+- General: Environment variables groups [\#2424](https://github.com/pypeclub/OpenPype/pull/2424)
+- Settings UI: Hyperlinks to settings [\#2420](https://github.com/pypeclub/OpenPype/pull/2420)
+- Modules: JobQueue module moved one hierarchy level higher [\#2419](https://github.com/pypeclub/OpenPype/pull/2419)
+- TimersManager: Start timer post launch hook [\#2418](https://github.com/pypeclub/OpenPype/pull/2418)
+- Ftrack: Check existence of object type on recreation [\#2404](https://github.com/pypeclub/OpenPype/pull/2404)
+- Flame: moving `utility\_scripts` to api folder also with `scripts` [\#2385](https://github.com/pypeclub/OpenPype/pull/2385)
+- Centos 7 dependency compatibility [\#2384](https://github.com/pypeclub/OpenPype/pull/2384)
+- Enhancement: Settings: Use project settings values from another project [\#2382](https://github.com/pypeclub/OpenPype/pull/2382)
+- Blender 3: Support auto install for new blender version [\#2377](https://github.com/pypeclub/OpenPype/pull/2377)
+- Maya add render image path to settings [\#2375](https://github.com/pypeclub/OpenPype/pull/2375)
+- Settings: Webpublisher in hosts enum [\#2367](https://github.com/pypeclub/OpenPype/pull/2367)
+- Hiero: python3 compatibility [\#2365](https://github.com/pypeclub/OpenPype/pull/2365)
+- Burnins: Be able recognize mxf OPAtom format [\#2361](https://github.com/pypeclub/OpenPype/pull/2361)
+- Maya: Add is\_static\_image\_plane and is\_in\_all\_views option in imagePlaneLoader [\#2356](https://github.com/pypeclub/OpenPype/pull/2356)
+- Local settings: Copyable studio paths [\#2349](https://github.com/pypeclub/OpenPype/pull/2349)
+- General: Multilayer EXRs support [\#2315](https://github.com/pypeclub/OpenPype/pull/2315)
**π Bug fixes**
-- Fix - provider icons are pulled from a folder [\#2326](https://github.com/pypeclub/OpenPype/pull/2326)
-- InputLinks: Typo in "inputLinks" key [\#2314](https://github.com/pypeclub/OpenPype/pull/2314)
-- Deadline timeout and logging [\#2312](https://github.com/pypeclub/OpenPype/pull/2312)
-- nuke: do not multiply representation on class method [\#2311](https://github.com/pypeclub/OpenPype/pull/2311)
-- Workfiles tool: Fix task formatting [\#2306](https://github.com/pypeclub/OpenPype/pull/2306)
-- Delivery: Fix delivery paths created on windows [\#2302](https://github.com/pypeclub/OpenPype/pull/2302)
-- Maya: Deadline - fix limit groups [\#2295](https://github.com/pypeclub/OpenPype/pull/2295)
-- New Publisher: Fix mapping of indexes [\#2285](https://github.com/pypeclub/OpenPype/pull/2285)
-- Alternate site for site sync doesnt work for sequences [\#2284](https://github.com/pypeclub/OpenPype/pull/2284)
-- FFmpeg: Execute ffprobe using list of arguments instead of string command [\#2281](https://github.com/pypeclub/OpenPype/pull/2281)
-- Nuke: Anatomy fill data use task as dictionary [\#2278](https://github.com/pypeclub/OpenPype/pull/2278)
-- Bug: fix variable name \_asset\_id in workfiles application [\#2274](https://github.com/pypeclub/OpenPype/pull/2274)
-- Version handling fixes [\#2272](https://github.com/pypeclub/OpenPype/pull/2272)
+- PS: Introduced settings for invalid characters to use in ValidateNaming plugin [\#2417](https://github.com/pypeclub/OpenPype/pull/2417)
+- Settings UI: Breadcrumbs path does not create new entities [\#2416](https://github.com/pypeclub/OpenPype/pull/2416)
+- AfterEffects: Variant 2022 is in defaults but missing in schemas [\#2412](https://github.com/pypeclub/OpenPype/pull/2412)
+- Nuke: baking representations was not additive [\#2406](https://github.com/pypeclub/OpenPype/pull/2406)
+- General: Fix access to environments from default settings [\#2403](https://github.com/pypeclub/OpenPype/pull/2403)
+- Fix: Placeholder Input color set fix [\#2399](https://github.com/pypeclub/OpenPype/pull/2399)
+- Settings: Fix state change of wrapper label [\#2396](https://github.com/pypeclub/OpenPype/pull/2396)
+- Flame: fix ftrack publisher [\#2381](https://github.com/pypeclub/OpenPype/pull/2381)
+- hiero: solve custom ocio path [\#2379](https://github.com/pypeclub/OpenPype/pull/2379)
+- hiero: fix workio and flatten [\#2378](https://github.com/pypeclub/OpenPype/pull/2378)
+- Nuke: fixing menu re-drawing during context change [\#2374](https://github.com/pypeclub/OpenPype/pull/2374)
+- Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373)
+- Nuke: fixing node name based on switched asset name [\#2369](https://github.com/pypeclub/OpenPype/pull/2369)
+- JobQueue: Fix loading of settings [\#2362](https://github.com/pypeclub/OpenPype/pull/2362)
+- Tools: Placeholder color [\#2359](https://github.com/pypeclub/OpenPype/pull/2359)
+- Launcher: Minimize button on MacOs [\#2355](https://github.com/pypeclub/OpenPype/pull/2355)
+- StandalonePublisher: Fix import of constant [\#2354](https://github.com/pypeclub/OpenPype/pull/2354)
+- Adobe products show issue [\#2347](https://github.com/pypeclub/OpenPype/pull/2347)
+- Remove wrongly used host for hook [\#2342](https://github.com/pypeclub/OpenPype/pull/2342)
+- Tools: Use Qt context on tools show [\#2340](https://github.com/pypeclub/OpenPype/pull/2340)
+- Flame: Fix default argument value in custom dictionary [\#2339](https://github.com/pypeclub/OpenPype/pull/2339)
+- Royal Render: Fix plugin order and OpenPype auto-detection [\#2291](https://github.com/pypeclub/OpenPype/pull/2291)
+
+**Merged pull requests:**
+
+- \[Fix\]\[MAYA\] Handle message type attribute within CollectLook [\#2394](https://github.com/pypeclub/OpenPype/pull/2394)
+- Add validator to check correct version of extension for PS and AE [\#2387](https://github.com/pypeclub/OpenPype/pull/2387)
+- Linux : flip updating submodules logic [\#2357](https://github.com/pypeclub/OpenPype/pull/2357)
+- Update of avalon-core [\#2346](https://github.com/pypeclub/OpenPype/pull/2346)
## [3.6.4](https://github.com/pypeclub/OpenPype/tree/3.6.4) (2021-11-23)
@@ -64,28 +79,6 @@
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.6.2-nightly.2...3.6.2)
-**π Enhancements**
-
-- Royal Render: Support for rr channels in separate dirs [\#2268](https://github.com/pypeclub/OpenPype/pull/2268)
-- SceneInventory: Choose loader in asset switcher [\#2262](https://github.com/pypeclub/OpenPype/pull/2262)
-- Style: New fonts in OpenPype style [\#2256](https://github.com/pypeclub/OpenPype/pull/2256)
-- Tools: SceneInventory in OpenPype [\#2255](https://github.com/pypeclub/OpenPype/pull/2255)
-- Tools: Tasks widget [\#2251](https://github.com/pypeclub/OpenPype/pull/2251)
-- Added endpoint for configured extensions [\#2221](https://github.com/pypeclub/OpenPype/pull/2221)
-
-**π Bug fixes**
-
-- Tools: Parenting of tools in Nuke and Hiero [\#2266](https://github.com/pypeclub/OpenPype/pull/2266)
-- limiting validator to specific editorial hosts [\#2264](https://github.com/pypeclub/OpenPype/pull/2264)
-- Tools: Select Context dialog attribute fix [\#2261](https://github.com/pypeclub/OpenPype/pull/2261)
-- Maya: Render publishing fails on linux [\#2260](https://github.com/pypeclub/OpenPype/pull/2260)
-- LookAssigner: Fix tool reopen [\#2259](https://github.com/pypeclub/OpenPype/pull/2259)
-- Standalone: editorial not publishing thumbnails on all subsets [\#2258](https://github.com/pypeclub/OpenPype/pull/2258)
-- Loader doesn't allow changing of version before loading [\#2254](https://github.com/pypeclub/OpenPype/pull/2254)
-- Burnins: Support mxf metadata [\#2247](https://github.com/pypeclub/OpenPype/pull/2247)
-- Maya: Support for configurable AOV separator characters [\#2197](https://github.com/pypeclub/OpenPype/pull/2197)
-- Maya: texture colorspace modes in looks [\#2195](https://github.com/pypeclub/OpenPype/pull/2195)
-
## [3.6.1](https://github.com/pypeclub/OpenPype/tree/3.6.1) (2021-11-16)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.6.1-nightly.1...3.6.1)
@@ -94,49 +87,6 @@
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.6.0-nightly.6...3.6.0)
-### π Documentation
-
-- Add alternative sites for Site Sync [\#2206](https://github.com/pypeclub/OpenPype/pull/2206)
-- Add command line way of running site sync server [\#2188](https://github.com/pypeclub/OpenPype/pull/2188)
-
-**π Enhancements**
-
-- Tools: Creator in OpenPype [\#2244](https://github.com/pypeclub/OpenPype/pull/2244)
-- Tools: Subset manager in OpenPype [\#2243](https://github.com/pypeclub/OpenPype/pull/2243)
-- General: Skip module directories without init file [\#2239](https://github.com/pypeclub/OpenPype/pull/2239)
-- General: Static interfaces [\#2238](https://github.com/pypeclub/OpenPype/pull/2238)
-- Style: Fix transparent image in style [\#2235](https://github.com/pypeclub/OpenPype/pull/2235)
-- Add a "following workfile versioning" option on publish [\#2225](https://github.com/pypeclub/OpenPype/pull/2225)
-- Modules: Module can add cli commands [\#2224](https://github.com/pypeclub/OpenPype/pull/2224)
-- Webpublisher: Separate webpublisher logic [\#2222](https://github.com/pypeclub/OpenPype/pull/2222)
-- Add both side availability on Site Sync sites to Loader [\#2220](https://github.com/pypeclub/OpenPype/pull/2220)
-- Tools: Center loader and library loader on show [\#2219](https://github.com/pypeclub/OpenPype/pull/2219)
-- Maya : Validate shape zero [\#2212](https://github.com/pypeclub/OpenPype/pull/2212)
-- Maya : validate unique names [\#2211](https://github.com/pypeclub/OpenPype/pull/2211)
-- Tools: OpenPype stylesheet in workfiles tool [\#2208](https://github.com/pypeclub/OpenPype/pull/2208)
-- Ftrack: Replace Queue with deque in event handlers logic [\#2204](https://github.com/pypeclub/OpenPype/pull/2204)
-- Tools: New select context dialog [\#2200](https://github.com/pypeclub/OpenPype/pull/2200)
-- Maya : Validate mesh ngons [\#2199](https://github.com/pypeclub/OpenPype/pull/2199)
-- Dirmap in Nuke [\#2198](https://github.com/pypeclub/OpenPype/pull/2198)
-- Delivery: Check 'frame' key in template for sequence delivery [\#2196](https://github.com/pypeclub/OpenPype/pull/2196)
-- Settings: Site sync project settings improvement [\#2193](https://github.com/pypeclub/OpenPype/pull/2193)
-- Usage of tools code [\#2185](https://github.com/pypeclub/OpenPype/pull/2185)
-
-**π Bug fixes**
-
-- Ftrack: Sync project ftrack id cache issue [\#2250](https://github.com/pypeclub/OpenPype/pull/2250)
-- Ftrack: Session creation and Prepare project [\#2245](https://github.com/pypeclub/OpenPype/pull/2245)
-- Added queue for studio processing in PS [\#2237](https://github.com/pypeclub/OpenPype/pull/2237)
-- Python 2: Unicode to string conversion [\#2236](https://github.com/pypeclub/OpenPype/pull/2236)
-- Fix - enum for color coding in PS [\#2234](https://github.com/pypeclub/OpenPype/pull/2234)
-- Pyblish Tool: Fix targets handling [\#2232](https://github.com/pypeclub/OpenPype/pull/2232)
-- Ftrack: Base event fix of 'get\_project\_from\_entity' method [\#2214](https://github.com/pypeclub/OpenPype/pull/2214)
-- Maya : multiple subsets review broken [\#2210](https://github.com/pypeclub/OpenPype/pull/2210)
-- Fix - different command used for Linux and Mac OS [\#2207](https://github.com/pypeclub/OpenPype/pull/2207)
-- Tools: Workfiles tool don't use avalon widgets [\#2205](https://github.com/pypeclub/OpenPype/pull/2205)
-- Ftrack: Fill missing ftrack id on mongo project [\#2203](https://github.com/pypeclub/OpenPype/pull/2203)
-- Project Manager: Fix copying of tasks [\#2191](https://github.com/pypeclub/OpenPype/pull/2191)
-
## [3.5.0](https://github.com/pypeclub/OpenPype/tree/3.5.0) (2021-10-17)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.5.0-nightly.8...3.5.0)
diff --git a/Dockerfile.centos7 b/Dockerfile.centos7
index f3b257e66b..736a42663c 100644
--- a/Dockerfile.centos7
+++ b/Dockerfile.centos7
@@ -41,6 +41,8 @@ RUN yum -y install https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.n
ncurses \
ncurses-devel \
qt5-qtbase-devel \
+ xcb-util-wm \
+ xcb-util-renderutil \
&& yum clean all
# we need to build our own patchelf
@@ -92,7 +94,8 @@ RUN source $HOME/.bashrc \
RUN cp /usr/lib64/libffi* ./build/exe.linux-x86_64-3.7/lib \
&& cp /usr/lib64/libssl* ./build/exe.linux-x86_64-3.7/lib \
&& cp /usr/lib64/libcrypto* ./build/exe.linux-x86_64-3.7/lib \
- && cp /root/.pyenv/versions/${OPENPYPE_PYTHON_VERSION}/lib/libpython* ./build/exe.linux-x86_64-3.7/lib
+ && cp /root/.pyenv/versions/${OPENPYPE_PYTHON_VERSION}/lib/libpython* ./build/exe.linux-x86_64-3.7/lib \
+ && cp /usr/lib64/libxcb* ./build/exe.linux-x86_64-3.7/vendor/python/PySide2/Qt/lib
RUN cd /opt/openpype \
rm -rf ./vendor/bin
diff --git a/openpype/api.py b/openpype/api.py
index a6529202ff..51854492ab 100644
--- a/openpype/api.py
+++ b/openpype/api.py
@@ -31,8 +31,6 @@ from .lib import (
)
from .lib.mongo import (
- decompose_url,
- compose_url,
get_default_components
)
@@ -84,8 +82,6 @@ __all__ = [
"Anatomy",
"config",
"execute",
- "decompose_url",
- "compose_url",
"get_default_components",
"ApplicationManager",
"BuildWorkfile",
diff --git a/openpype/cli.py b/openpype/cli.py
index 4c4dc1a3c6..6e9c237b0e 100644
--- a/openpype/cli.py
+++ b/openpype/cli.py
@@ -138,7 +138,10 @@ def webpublisherwebserver(debug, executable, upload_dir, host=None, port=None):
@click.option("--asset", help="Asset name", default=None)
@click.option("--task", help="Task name", default=None)
@click.option("--app", help="Application name", default=None)
-def extractenvironments(output_json_path, project, asset, task, app):
+@click.option(
+ "--envgroup", help="Environment group (e.g. \"farm\")", default=None
+)
+def extractenvironments(output_json_path, project, asset, task, app, envgroup):
"""Extract environment variables for entered context to a json file.
Entered output filepath will be created if does not exists.
@@ -149,7 +152,7 @@ def extractenvironments(output_json_path, project, asset, task, app):
Context options are "project", "asset", "task", "app"
"""
PypeCommands.extractenvironments(
- output_json_path, project, asset, task, app
+ output_json_path, project, asset, task, app, envgroup
)
@@ -356,9 +359,22 @@ def run(script):
"--pyargs",
help="Run tests from package",
default=None)
-def runtests(folder, mark, pyargs):
+@click.option("-t",
+ "--test_data_folder",
+ help="Unzipped directory path of test file",
+ default=None)
+@click.option("-s",
+ "--persist",
+ help="Persist test DB and published files after test end",
+ default=None)
+@click.option("-a",
+ "--app_variant",
+ help="Provide specific app variant for test, empty for latest",
+ default=None)
+def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant):
"""Run all automatic tests after proper initialization via start.py"""
- PypeCommands().run_tests(folder, mark, pyargs)
+ PypeCommands().run_tests(folder, mark, pyargs, test_data_folder,
+ persist, app_variant)
@main.command()
diff --git a/openpype/hooks/pre_foundry_apps.py b/openpype/hooks/pre_foundry_apps.py
index 7df1a6a833..85f68c6b60 100644
--- a/openpype/hooks/pre_foundry_apps.py
+++ b/openpype/hooks/pre_foundry_apps.py
@@ -13,7 +13,7 @@ class LaunchFoundryAppsWindows(PreLaunchHook):
# Should be as last hook because must change launch arguments to string
order = 1000
- app_groups = ["nuke", "nukex", "hiero", "nukestudio", "photoshop"]
+ app_groups = ["nuke", "nukex", "hiero", "nukestudio"]
platforms = ["windows"]
def execute(self):
diff --git a/openpype/hooks/pre_global_host_data.py b/openpype/hooks/pre_global_host_data.py
index b32fb5e44a..6b08cdb444 100644
--- a/openpype/hooks/pre_global_host_data.py
+++ b/openpype/hooks/pre_global_host_data.py
@@ -48,7 +48,7 @@ class GlobalHostDataHook(PreLaunchHook):
"log": self.log
})
- prepare_host_environments(temp_data)
+ prepare_host_environments(temp_data, self.launch_context.env_group)
prepare_context_environments(temp_data)
temp_data.pop("log")
diff --git a/openpype/hooks/pre_non_python_host_launch.py b/openpype/hooks/pre_non_python_host_launch.py
index 0447f4a06f..848ed675a8 100644
--- a/openpype/hooks/pre_non_python_host_launch.py
+++ b/openpype/hooks/pre_non_python_host_launch.py
@@ -49,7 +49,3 @@ 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.DEVNULL
diff --git a/openpype/hosts/aftereffects/plugins/publish/closeAE.py b/openpype/hosts/aftereffects/plugins/publish/closeAE.py
new file mode 100644
index 0000000000..21bedf0125
--- /dev/null
+++ b/openpype/hosts/aftereffects/plugins/publish/closeAE.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+"""Close AE after publish. For Webpublishing only."""
+import pyblish.api
+
+from avalon import aftereffects
+
+
+class CloseAE(pyblish.api.ContextPlugin):
+ """Close AE after publish. For Webpublishing only.
+ """
+
+ order = pyblish.api.IntegratorOrder + 14
+ label = "Close AE"
+ optional = True
+ active = True
+
+ hosts = ["aftereffects"]
+ targets = ["remotepublish"]
+
+ def process(self, context):
+ self.log.info("CloseAE")
+
+ stub = aftereffects.stub()
+ self.log.info("Shutting down AE")
+ stub.save()
+ stub.close()
+ self.log.info("AE closed")
diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_current_file.py b/openpype/hosts/aftereffects/plugins/publish/collect_current_file.py
index b59ff41a0e..51f6f5c844 100644
--- a/openpype/hosts/aftereffects/plugins/publish/collect_current_file.py
+++ b/openpype/hosts/aftereffects/plugins/publish/collect_current_file.py
@@ -8,7 +8,7 @@ from avalon import aftereffects
class CollectCurrentFile(pyblish.api.ContextPlugin):
"""Inject the current working file into context"""
- order = pyblish.api.CollectorOrder - 0.5
+ order = pyblish.api.CollectorOrder - 0.49
label = "Current File"
hosts = ["aftereffects"]
diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_extension_version.py b/openpype/hosts/aftereffects/plugins/publish/collect_extension_version.py
new file mode 100644
index 0000000000..4e74252043
--- /dev/null
+++ b/openpype/hosts/aftereffects/plugins/publish/collect_extension_version.py
@@ -0,0 +1,56 @@
+import os
+import re
+import pyblish.api
+
+from avalon import aftereffects
+
+
+class CollectExtensionVersion(pyblish.api.ContextPlugin):
+ """ Pulls and compares version of installed extension.
+
+ It is recommended to use same extension as in provided Openpype code.
+
+ Please use Anastasiyβs Extension Manager or ZXPInstaller to update
+ extension in case of an error.
+
+ You can locate extension.zxp in your installed Openpype code in
+ `repos/avalon-core/avalon/aftereffects`
+ """
+ # This technically should be a validator, but other collectors might be
+ # impacted with usage of obsolete extension, so collector that runs first
+ # was chosen
+ order = pyblish.api.CollectorOrder - 0.5
+ label = "Collect extension version"
+ hosts = ["aftereffects"]
+
+ optional = True
+ active = True
+
+ def process(self, context):
+ installed_version = aftereffects.stub().get_extension_version()
+
+ if not installed_version:
+ raise ValueError("Unknown version, probably old extension")
+
+ manifest_url = os.path.join(os.path.dirname(aftereffects.__file__),
+ "extension", "CSXS", "manifest.xml")
+
+ if not os.path.exists(manifest_url):
+ self.log.debug("Unable to locate extension manifest, not checking")
+ return
+
+ expected_version = None
+ with open(manifest_url) as fp:
+ content = fp.read()
+ found = re.findall(r'(ExtensionBundleVersion=")([0-9\.]+)(")',
+ content)
+ if found:
+ expected_version = found[0][1]
+
+ if expected_version != installed_version:
+ msg = (
+ "Expected version '{}' found '{}'\n Please update"
+ " your installed extension, it might not work properly."
+ ).format(expected_version, installed_version)
+
+ raise ValueError(msg)
diff --git a/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py b/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py
index 37337e7fee..b36ab24bde 100644
--- a/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py
+++ b/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py
@@ -19,10 +19,9 @@ class ExtractLocalRender(openpype.api.Extractor):
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()
+ stub.render(staging_dir)
if not render_q:
raise ValueError("No file extension set in Render Queue")
_, ext = os.path.splitext(os.path.basename(render_q.file_name))
diff --git a/openpype/hosts/blender/hooks/pre_pyside_install.py b/openpype/hosts/blender/hooks/pre_pyside_install.py
index 6d253300d9..e2a419c8ef 100644
--- a/openpype/hosts/blender/hooks/pre_pyside_install.py
+++ b/openpype/hosts/blender/hooks/pre_pyside_install.py
@@ -32,7 +32,7 @@ class InstallPySideToBlender(PreLaunchHook):
def inner_execute(self):
# Get blender's python directory
- version_regex = re.compile(r"^2\.[0-9]{2}$")
+ version_regex = re.compile(r"^[2-3]\.[0-9]+$")
executable = self.launch_context.executable.executable_path
if os.path.basename(executable).lower() != "blender.exe":
diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py
index 48331dcbc2..89e020b329 100644
--- a/openpype/hosts/flame/api/lib.py
+++ b/openpype/hosts/flame/api/lib.py
@@ -47,10 +47,8 @@ class FlameAppFramework(object):
def setdefault(self, k, default=None):
return self.master[self.name].setdefault(k, default)
- def pop(self, k, v=object()):
- if v is object():
- return self.master[self.name].pop(k)
- return self.master[self.name].pop(k, v)
+ def pop(self, *args, **kwargs):
+ return self.master[self.name].pop(*args, **kwargs)
def update(self, mapping=(), **kwargs):
self.master[self.name].update(mapping, **kwargs)
diff --git a/openpype/hosts/flame/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py
similarity index 100%
rename from openpype/hosts/flame/scripts/wiretap_com.py
rename to openpype/hosts/flame/api/scripts/wiretap_com.py
diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/export_preset/openpype_seg_thumbnails_jpg.xml b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/export_preset/openpype_seg_thumbnails_jpg.xml
new file mode 100644
index 0000000000..fa43ceece7
--- /dev/null
+++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/export_preset/openpype_seg_thumbnails_jpg.xml
@@ -0,0 +1,58 @@
+
+
+ sequence
+ Creates a 8-bit Jpeg file per segment.
+
+ NONE
+
+ <name>
+ True
+ True
+
+ image
+ FX
+ NoChange
+ False
+ 10
+
+ True
+ False
+
+ audio
+ FX
+ FlattenTracks
+ True
+ 10
+
+
+
+
+ 4
+ 1
+ 2
+
+
diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/export_preset/openpype_seg_video_h264.xml b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/export_preset/openpype_seg_video_h264.xml
new file mode 100644
index 0000000000..3ca185b8b4
--- /dev/null
+++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/export_preset/openpype_seg_video_h264.xml
@@ -0,0 +1,72 @@
+
+
+ sequence
+ Create MOV H264 files per segment with thumbnail
+
+ NONE
+
+ <name>
+ True
+ True
+
+ movie
+ FX
+ FlattenTracks
+ True
+ 5
+
+ True
+ False
+
+ audio
+ Original
+ NoChange
+ True
+ 5
+
+
+
+ QuickTime
+ <segment name>
+ 0
+ PCS_709
+ None
+ Autodesk
+ Flame
+ 2021
+
+
+
+ 4
+ 1
+ 2
+
+
\ No newline at end of file
diff --git a/openpype/hosts/hiero/otio/__init__.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/__init__.py
similarity index 100%
rename from openpype/hosts/hiero/otio/__init__.py
rename to openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/__init__.py
diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/app_utils.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/app_utils.py
new file mode 100644
index 0000000000..b255d8d3f5
--- /dev/null
+++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/app_utils.py
@@ -0,0 +1,162 @@
+import os
+import io
+import ConfigParser as CP
+from xml.etree import ElementTree as ET
+from contextlib import contextmanager
+
+PLUGIN_DIR = os.path.dirname(os.path.dirname(__file__))
+EXPORT_PRESETS_DIR = os.path.join(PLUGIN_DIR, "export_preset")
+
+CONFIG_DIR = os.path.join(os.path.expanduser(
+ "~/.openpype"), "openpype_flame_to_ftrack")
+
+
+@contextmanager
+def make_temp_dir():
+ import tempfile
+
+ try:
+ dirpath = tempfile.mkdtemp()
+
+ yield dirpath
+
+ except IOError as _error:
+ raise IOError("Not able to create temp dir file: {}".format(_error))
+
+ finally:
+ pass
+
+
+@contextmanager
+def get_config(section=None):
+ cfg_file_path = os.path.join(CONFIG_DIR, "settings.ini")
+
+ # create config dir
+ if not os.path.exists(CONFIG_DIR):
+ print("making dirs at: `{}`".format(CONFIG_DIR))
+ os.makedirs(CONFIG_DIR, mode=0o777)
+
+ # write default data to settings.ini
+ if not os.path.exists(cfg_file_path):
+ default_cfg = cfg_default()
+ config = CP.RawConfigParser()
+ config.readfp(io.BytesIO(default_cfg))
+ with open(cfg_file_path, 'wb') as cfg_file:
+ config.write(cfg_file)
+
+ try:
+ config = CP.RawConfigParser()
+ config.read(cfg_file_path)
+ if section:
+ _cfg_data = {
+ k: v
+ for s in config.sections()
+ for k, v in config.items(s)
+ if s == section
+ }
+ else:
+ _cfg_data = {s: dict(config.items(s)) for s in config.sections()}
+
+ yield _cfg_data
+
+ except IOError as _error:
+ raise IOError('Not able to read settings.ini file: {}'.format(_error))
+
+ finally:
+ pass
+
+
+def set_config(cfg_data, section=None):
+ cfg_file_path = os.path.join(CONFIG_DIR, "settings.ini")
+
+ config = CP.RawConfigParser()
+ config.read(cfg_file_path)
+
+ try:
+ if not section:
+ for section in cfg_data:
+ for key, value in cfg_data[section].items():
+ config.set(section, key, value)
+ else:
+ for key, value in cfg_data.items():
+ config.set(section, key, value)
+
+ with open(cfg_file_path, 'wb') as cfg_file:
+ config.write(cfg_file)
+
+ except IOError as _error:
+ raise IOError('Not able to write settings.ini file: {}'.format(_error))
+
+
+def cfg_default():
+ return """
+[main]
+workfile_start_frame = 1001
+shot_handles = 0
+shot_name_template = {sequence}_{shot}
+hierarchy_template = shots[Folder]/{sequence}[Sequence]
+create_task_type = Compositing
+"""
+
+
+def configure_preset(file_path, data):
+ split_fp = os.path.splitext(file_path)
+ new_file_path = split_fp[0] + "_tmp" + split_fp[-1]
+ with open(file_path, "r") as datafile:
+ tree = ET.parse(datafile)
+ for key, value in data.items():
+ for element in tree.findall(".//{}".format(key)):
+ print(element)
+ element.text = str(value)
+ tree.write(new_file_path)
+
+ return new_file_path
+
+
+def export_thumbnail(sequence, tempdir_path, data):
+ import flame
+ export_preset = os.path.join(
+ EXPORT_PRESETS_DIR,
+ "openpype_seg_thumbnails_jpg.xml"
+ )
+ new_path = configure_preset(export_preset, data)
+ poster_frame_exporter = flame.PyExporter()
+ poster_frame_exporter.foreground = True
+ poster_frame_exporter.export(sequence, new_path, tempdir_path)
+
+
+def export_video(sequence, tempdir_path, data):
+ import flame
+ export_preset = os.path.join(
+ EXPORT_PRESETS_DIR,
+ "openpype_seg_video_h264.xml"
+ )
+ new_path = configure_preset(export_preset, data)
+ poster_frame_exporter = flame.PyExporter()
+ poster_frame_exporter.foreground = True
+ poster_frame_exporter.export(sequence, new_path, tempdir_path)
+
+
+def timecode_to_frames(timecode, framerate):
+ def _seconds(value):
+ if isinstance(value, str):
+ _zip_ft = zip((3600, 60, 1, 1 / framerate), value.split(':'))
+ return sum(f * float(t) for f, t in _zip_ft)
+ elif isinstance(value, (int, float)):
+ return value / framerate
+ return 0
+
+ def _frames(seconds):
+ return seconds * framerate
+
+ def tc_to_frames(_timecode, start=None):
+ return _frames(_seconds(_timecode) - _seconds(start))
+
+ if '+' in timecode:
+ timecode = timecode.replace('+', ':')
+ elif '#' in timecode:
+ timecode = timecode.replace('#', ':')
+
+ frames = int(round(tc_to_frames(timecode, start='00:00:00:00')))
+
+ return frames
diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py
new file mode 100644
index 0000000000..26b197ee1d
--- /dev/null
+++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py
@@ -0,0 +1,448 @@
+import os
+import sys
+import six
+import re
+import json
+
+import app_utils
+
+# Fill following constants or set them via environment variable
+FTRACK_MODULE_PATH = None
+FTRACK_API_KEY = None
+FTRACK_API_USER = None
+FTRACK_SERVER = None
+
+
+def import_ftrack_api():
+ try:
+ import ftrack_api
+ return ftrack_api
+ except ImportError:
+ import sys
+ ftrk_m_p = FTRACK_MODULE_PATH or os.getenv("FTRACK_MODULE_PATH")
+ sys.path.append(ftrk_m_p)
+ import ftrack_api
+ return ftrack_api
+
+
+def get_ftrack_session():
+ import os
+ ftrack_api = import_ftrack_api()
+
+ # fill your own credentials
+ url = FTRACK_SERVER or os.getenv("FTRACK_SERVER") or ""
+ user = FTRACK_API_USER or os.getenv("FTRACK_API_USER") or ""
+ api = FTRACK_API_KEY or os.getenv("FTRACK_API_KEY") or ""
+
+ first_validation = True
+ if not user:
+ print('- Ftrack Username is not set')
+ first_validation = False
+ if not api:
+ print('- Ftrack API key is not set')
+ first_validation = False
+ if not first_validation:
+ return False
+
+ try:
+ return ftrack_api.Session(
+ server_url=url,
+ api_user=user,
+ api_key=api
+ )
+ except Exception as _e:
+ print("Can't log into Ftrack with used credentials: {}".format(_e))
+ ftrack_cred = {
+ 'Ftrack server': str(url),
+ 'Username': str(user),
+ 'API key': str(api),
+ }
+
+ item_lens = [len(key) + 1 for key in ftrack_cred]
+ justify_len = max(*item_lens)
+ for key, value in ftrack_cred.items():
+ print('{} {}'.format((key + ':').ljust(justify_len, ' '), value))
+ return False
+
+
+def get_project_task_types(project_entity):
+ tasks = {}
+ proj_template = project_entity['project_schema']
+ temp_task_types = proj_template['_task_type_schema']['types']
+
+ for type in temp_task_types:
+ if type['name'] not in tasks:
+ tasks[type['name']] = type
+
+ return tasks
+
+
+class FtrackComponentCreator:
+ default_location = "ftrack.server"
+ ftrack_locations = {}
+ thumbnails = []
+ videos = []
+ temp_dir = None
+
+ def __init__(self, session):
+ self.session = session
+ self._get_ftrack_location()
+
+ def generate_temp_data(self, selection, change_preset_data):
+ with app_utils.make_temp_dir() as tempdir_path:
+ for seq in selection:
+ app_utils.export_thumbnail(
+ seq, tempdir_path, change_preset_data)
+ app_utils.export_video(seq, tempdir_path, change_preset_data)
+
+ return tempdir_path
+
+ def collect_generated_data(self, tempdir_path):
+ temp_files = os.listdir(tempdir_path)
+ self.thumbnails = [f for f in temp_files if "jpg" in f]
+ self.videos = [f for f in temp_files if "mov" in f]
+ self.temp_dir = tempdir_path
+
+ def get_thumb_path(self, shot_name):
+ # get component files
+ thumb_f = next((f for f in self.thumbnails if shot_name in f), None)
+ return os.path.join(self.temp_dir, thumb_f)
+
+ def get_video_path(self, shot_name):
+ # get component files
+ video_f = next((f for f in self.videos if shot_name in f), None)
+ return os.path.join(self.temp_dir, video_f)
+
+ def close(self):
+ self.ftrack_locations = {}
+ self.session = None
+
+ def create_comonent(self, shot_entity, data, assetversion_entity=None):
+ self.shot_entity = shot_entity
+ location = self._get_ftrack_location()
+
+ file_path = data["file_path"]
+
+ # get extension
+ file = os.path.basename(file_path)
+ _n, ext = os.path.splitext(file)
+
+ name = "ftrackreview-mp4" if "mov" in ext else "thumbnail"
+
+ component_data = {
+ "name": name,
+ "file_path": file_path,
+ "file_type": ext,
+ "location": location
+ }
+
+ if name == "ftrackreview-mp4":
+ duration = data["duration"]
+ handles = data["handles"]
+ fps = data["fps"]
+ component_data["metadata"] = {
+ 'ftr_meta': json.dumps({
+ 'frameIn': int(0),
+ 'frameOut': int(duration + (handles * 2)),
+ 'frameRate': float(fps)
+ })
+ }
+ if not assetversion_entity:
+ # get assettype entity from session
+ assettype_entity = self._get_assettype({"short": "reference"})
+
+ # get or create asset entity from session
+ asset_entity = self._get_asset({
+ "name": "plateReference",
+ "type": assettype_entity,
+ "parent": self.shot_entity
+ })
+
+ # get or create assetversion entity from session
+ assetversion_entity = self._get_assetversion({
+ "version": 0,
+ "asset": asset_entity
+ })
+
+ # get or create component entity
+ self._set_component(component_data, {
+ "name": name,
+ "version": assetversion_entity,
+ })
+
+ return assetversion_entity
+
+ def _overwrite_members(self, entity, data):
+ origin_location = self._get_ftrack_location("ftrack.origin")
+ location = data.pop("location")
+
+ self._remove_component_from_location(entity, location)
+
+ entity["file_type"] = data["file_type"]
+
+ try:
+ origin_location.add_component(
+ entity, data["file_path"]
+ )
+ # Add components to location.
+ location.add_component(
+ entity, origin_location, recursive=True)
+ except Exception as __e:
+ print("Error: {}".format(__e))
+ self._remove_component_from_location(entity, origin_location)
+ origin_location.add_component(
+ entity, data["file_path"]
+ )
+ # Add components to location.
+ location.add_component(
+ entity, origin_location, recursive=True)
+
+ def _remove_component_from_location(self, entity, location):
+ print(location)
+ # Removing existing members from location
+ components = list(entity.get("members", []))
+ components += [entity]
+ for component in components:
+ for loc in component.get("component_locations", []):
+ if location["id"] == loc["location_id"]:
+ print("<< Removing component: {}".format(component))
+ location.remove_component(
+ component, recursive=False
+ )
+
+ # Deleting existing members on component entity
+ for member in entity.get("members", []):
+ self.session.delete(member)
+ print("<< Deleting member: {}".format(member))
+ del(member)
+
+ self._commit()
+
+ # Reset members in memory
+ if "members" in entity.keys():
+ entity["members"] = []
+
+ def _get_assettype(self, data):
+ return self.session.query(
+ self._query("AssetType", data)).first()
+
+ def _set_component(self, comp_data, base_data):
+ component_metadata = comp_data.pop("metadata", {})
+
+ component_entity = self.session.query(
+ self._query("Component", base_data)
+ ).first()
+
+ if component_entity:
+ # overwrite existing members in component enity
+ # - get data for member from `ftrack.origin` location
+ self._overwrite_members(component_entity, comp_data)
+
+ # Adding metadata
+ existing_component_metadata = component_entity["metadata"]
+ existing_component_metadata.update(component_metadata)
+ component_entity["metadata"] = existing_component_metadata
+ return
+
+ assetversion_entity = base_data["version"]
+ location = comp_data.pop("location")
+
+ component_entity = assetversion_entity.create_component(
+ comp_data["file_path"],
+ data=comp_data,
+ location=location
+ )
+
+ # Adding metadata
+ existing_component_metadata = component_entity["metadata"]
+ existing_component_metadata.update(component_metadata)
+ component_entity["metadata"] = existing_component_metadata
+
+ if comp_data["name"] == "thumbnail":
+ self.shot_entity["thumbnail_id"] = component_entity["id"]
+ assetversion_entity["thumbnail_id"] = component_entity["id"]
+
+ self._commit()
+
+ def _get_asset(self, data):
+ # first find already created
+ asset_entity = self.session.query(
+ self._query("Asset", data)
+ ).first()
+
+ if asset_entity:
+ return asset_entity
+
+ asset_entity = self.session.create("Asset", data)
+
+ # _commit if created
+ self._commit()
+
+ return asset_entity
+
+ def _get_assetversion(self, data):
+ assetversion_entity = self.session.query(
+ self._query("AssetVersion", data)
+ ).first()
+
+ if assetversion_entity:
+ return assetversion_entity
+
+ assetversion_entity = self.session.create("AssetVersion", data)
+
+ # _commit if created
+ self._commit()
+
+ return assetversion_entity
+
+ def _commit(self):
+ try:
+ self.session.commit()
+ except Exception:
+ tp, value, tb = sys.exc_info()
+ # self.session.rollback()
+ # self.session._configure_locations()
+ six.reraise(tp, value, tb)
+
+ def _get_ftrack_location(self, name=None):
+ name = name or self.default_location
+
+ if name in self.ftrack_locations:
+ return self.ftrack_locations[name]
+
+ location = self.session.query(
+ 'Location where name is "{}"'.format(name)
+ ).one()
+ self.ftrack_locations[name] = location
+ return location
+
+ def _query(self, entitytype, data):
+ """ Generate a query expression from data supplied.
+
+ If a value is not a string, we'll add the id of the entity to the
+ query.
+
+ Args:
+ entitytype (str): The type of entity to query.
+ data (dict): The data to identify the entity.
+ exclusions (list): All keys to exclude from the query.
+
+ Returns:
+ str: String query to use with "session.query"
+ """
+ queries = []
+ if sys.version_info[0] < 3:
+ for key, value in data.items():
+ if not isinstance(value, (str, int)):
+ print("value: {}".format(value))
+ if "id" in value.keys():
+ queries.append(
+ "{0}.id is \"{1}\"".format(key, value["id"])
+ )
+ else:
+ queries.append("{0} is \"{1}\"".format(key, value))
+ else:
+ for key, value in data.items():
+ if not isinstance(value, (str, int)):
+ print("value: {}".format(value))
+ if "id" in value.keys():
+ queries.append(
+ "{0}.id is \"{1}\"".format(key, value["id"])
+ )
+ else:
+ queries.append("{0} is \"{1}\"".format(key, value))
+
+ query = (
+ "select id from " + entitytype + " where " + " and ".join(queries)
+ )
+ print(query)
+ return query
+
+
+class FtrackEntityOperator:
+ def __init__(self, session, project_entity):
+ self.session = session
+ self.project_entity = project_entity
+
+ def commit(self):
+ try:
+ self.session.commit()
+ except Exception:
+ tp, value, tb = sys.exc_info()
+ self.session.rollback()
+ self.session._configure_locations()
+ six.reraise(tp, value, tb)
+
+ def create_ftrack_entity(self, session, type, name, parent=None):
+ parent = parent or self.project_entity
+ entity = session.create(type, {
+ 'name': name,
+ 'parent': parent
+ })
+ try:
+ session.commit()
+ except Exception:
+ tp, value, tb = sys.exc_info()
+ session.rollback()
+ session._configure_locations()
+ six.reraise(tp, value, tb)
+ return entity
+
+ def get_ftrack_entity(self, session, type, name, parent):
+ query = '{} where name is "{}" and project_id is "{}"'.format(
+ type, name, self.project_entity["id"])
+
+ try:
+ entity = session.query(query).one()
+ except Exception:
+ entity = None
+
+ # if entity doesnt exist then create one
+ if not entity:
+ entity = self.create_ftrack_entity(
+ session,
+ type,
+ name,
+ parent
+ )
+
+ return entity
+
+ def create_parents(self, template):
+ parents = []
+ t_split = template.split("/")
+ replace_patern = re.compile(r"(\[.*\])")
+ type_patern = re.compile(r"\[(.*)\]")
+
+ for t_s in t_split:
+ match_type = type_patern.findall(t_s)
+ if not match_type:
+ raise Exception((
+ "Missing correct type flag in : {}"
+ "/n Example: name[Type]").format(
+ t_s)
+ )
+ new_name = re.sub(replace_patern, "", t_s)
+ f_type = match_type.pop()
+
+ parents.append((new_name, f_type))
+
+ return parents
+
+ def create_task(self, task_type, task_types, parent):
+ existing_task = [
+ child for child in parent['children']
+ if child.entity_type.lower() == 'task'
+ if child['name'].lower() in task_type.lower()
+ ]
+
+ if existing_task:
+ return existing_task.pop()
+
+ task = self.session.create('Task', {
+ "name": task_type.lower(),
+ "parent": parent
+ })
+ task["type"] = task_types[task_type]
+
+ return task
diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/panel_app.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/panel_app.py
new file mode 100644
index 0000000000..9e39147776
--- /dev/null
+++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/panel_app.py
@@ -0,0 +1,524 @@
+from PySide2 import QtWidgets, QtCore
+
+import uiwidgets
+import app_utils
+import ftrack_lib
+
+
+def clear_inner_modules():
+ import sys
+
+ if "ftrack_lib" in sys.modules.keys():
+ del sys.modules["ftrack_lib"]
+ print("Ftrack Lib module removed from sys.modules")
+
+ if "app_utils" in sys.modules.keys():
+ del sys.modules["app_utils"]
+ print("app_utils module removed from sys.modules")
+
+ if "uiwidgets" in sys.modules.keys():
+ del sys.modules["uiwidgets"]
+ print("uiwidgets module removed from sys.modules")
+
+
+class MainWindow(QtWidgets.QWidget):
+
+ def __init__(self, klass, *args, **kwargs):
+ super(MainWindow, self).__init__(*args, **kwargs)
+ self.panel_class = klass
+
+ def closeEvent(self, event):
+ # clear all temp data
+ print("Removing temp data")
+ self.panel_class.clear_temp_data()
+ self.panel_class.close()
+ clear_inner_modules()
+ # now the panel can be closed
+ event.accept()
+
+
+class FlameToFtrackPanel(object):
+ session = None
+ temp_data_dir = None
+ processed_components = []
+ project_entity = None
+ task_types = {}
+ all_task_types = {}
+
+ # TreeWidget
+ columns = {
+ "Sequence name": {
+ "columnWidth": 200,
+ "order": 0
+ },
+ "Shot name": {
+ "columnWidth": 200,
+ "order": 1
+ },
+ "Clip duration": {
+ "columnWidth": 100,
+ "order": 2
+ },
+ "Shot description": {
+ "columnWidth": 500,
+ "order": 3
+ },
+ "Task description": {
+ "columnWidth": 500,
+ "order": 4
+ },
+ }
+
+ def __init__(self, selection):
+ print(selection)
+
+ self.session = ftrack_lib.get_ftrack_session()
+ self.selection = selection
+ self.window = MainWindow(self)
+
+ # creating ui
+ self.window.setMinimumSize(1500, 600)
+ self.window.setWindowTitle('Sequence Shots to Ftrack')
+ self.window.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
+ self.window.setAttribute(QtCore.Qt.WA_DeleteOnClose)
+ self.window.setFocusPolicy(QtCore.Qt.StrongFocus)
+ self.window.setStyleSheet('background-color: #313131')
+
+ self._create_project_widget()
+ self._create_tree_widget()
+ self._set_sequence_params()
+ self._generate_widgets()
+ self._generate_layouts()
+ self._timeline_info()
+ self._fix_resolution()
+
+ self.window.show()
+
+ def _generate_widgets(self):
+ with app_utils.get_config("main") as cfg_data:
+ cfg_d = cfg_data
+
+ self._create_task_type_widget(cfg_d)
+
+ # input fields
+ self.shot_name_label = uiwidgets.FlameLabel(
+ 'Shot name template', 'normal', self.window)
+ self.shot_name_template_input = uiwidgets.FlameLineEdit(
+ cfg_d["shot_name_template"], self.window)
+
+ self.hierarchy_label = uiwidgets.FlameLabel(
+ 'Parents template', 'normal', self.window)
+ self.hierarchy_template_input = uiwidgets.FlameLineEdit(
+ cfg_d["hierarchy_template"], self.window)
+
+ self.start_frame_label = uiwidgets.FlameLabel(
+ 'Workfile start frame', 'normal', self.window)
+ self.start_frame_input = uiwidgets.FlameLineEdit(
+ cfg_d["workfile_start_frame"], self.window)
+
+ self.handles_label = uiwidgets.FlameLabel(
+ 'Shot handles', 'normal', self.window)
+ self.handles_input = uiwidgets.FlameLineEdit(
+ cfg_d["shot_handles"], self.window)
+
+ self.width_label = uiwidgets.FlameLabel(
+ 'Sequence width', 'normal', self.window)
+ self.width_input = uiwidgets.FlameLineEdit(
+ str(self.seq_width), self.window)
+
+ self.height_label = uiwidgets.FlameLabel(
+ 'Sequence height', 'normal', self.window)
+ self.height_input = uiwidgets.FlameLineEdit(
+ str(self.seq_height), self.window)
+
+ self.pixel_aspect_label = uiwidgets.FlameLabel(
+ 'Pixel aspect ratio', 'normal', self.window)
+ self.pixel_aspect_input = uiwidgets.FlameLineEdit(
+ str(1.00), self.window)
+
+ self.fps_label = uiwidgets.FlameLabel(
+ 'Frame rate', 'normal', self.window)
+ self.fps_input = uiwidgets.FlameLineEdit(
+ str(self.fps), self.window)
+
+ # Button
+ self.select_all_btn = uiwidgets.FlameButton(
+ 'Select All', self.select_all, self.window)
+
+ self.remove_temp_data_btn = uiwidgets.FlameButton(
+ 'Remove temp data', self.clear_temp_data, self.window)
+
+ self.ftrack_send_btn = uiwidgets.FlameButton(
+ 'Send to Ftrack', self._send_to_ftrack, self.window)
+
+ def _generate_layouts(self):
+ # left props
+ v_shift = 0
+ prop_layout_l = QtWidgets.QGridLayout()
+ prop_layout_l.setHorizontalSpacing(30)
+ if self.project_selector_enabled:
+ prop_layout_l.addWidget(self.project_select_label, v_shift, 0)
+ prop_layout_l.addWidget(self.project_select_input, v_shift, 1)
+ v_shift += 1
+ prop_layout_l.addWidget(self.shot_name_label, (v_shift + 0), 0)
+ prop_layout_l.addWidget(
+ self.shot_name_template_input, (v_shift + 0), 1)
+ prop_layout_l.addWidget(self.hierarchy_label, (v_shift + 1), 0)
+ prop_layout_l.addWidget(
+ self.hierarchy_template_input, (v_shift + 1), 1)
+ prop_layout_l.addWidget(self.start_frame_label, (v_shift + 2), 0)
+ prop_layout_l.addWidget(self.start_frame_input, (v_shift + 2), 1)
+ prop_layout_l.addWidget(self.handles_label, (v_shift + 3), 0)
+ prop_layout_l.addWidget(self.handles_input, (v_shift + 3), 1)
+ prop_layout_l.addWidget(self.task_type_label, (v_shift + 4), 0)
+ prop_layout_l.addWidget(
+ self.task_type_input, (v_shift + 4), 1)
+
+ # right props
+ prop_widget_r = QtWidgets.QWidget(self.window)
+ prop_layout_r = QtWidgets.QGridLayout(prop_widget_r)
+ prop_layout_r.setHorizontalSpacing(30)
+ prop_layout_r.setAlignment(
+ QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
+ prop_layout_r.setContentsMargins(0, 0, 0, 0)
+ prop_layout_r.addWidget(self.width_label, 1, 0)
+ prop_layout_r.addWidget(self.width_input, 1, 1)
+ prop_layout_r.addWidget(self.height_label, 2, 0)
+ prop_layout_r.addWidget(self.height_input, 2, 1)
+ prop_layout_r.addWidget(self.pixel_aspect_label, 3, 0)
+ prop_layout_r.addWidget(self.pixel_aspect_input, 3, 1)
+ prop_layout_r.addWidget(self.fps_label, 4, 0)
+ prop_layout_r.addWidget(self.fps_input, 4, 1)
+
+ # prop layout
+ prop_main_layout = QtWidgets.QHBoxLayout()
+ prop_main_layout.addLayout(prop_layout_l, 1)
+ prop_main_layout.addSpacing(20)
+ prop_main_layout.addWidget(prop_widget_r, 1)
+
+ # buttons layout
+ hbox = QtWidgets.QHBoxLayout()
+ hbox.addWidget(self.remove_temp_data_btn)
+ hbox.addWidget(self.select_all_btn)
+ hbox.addWidget(self.ftrack_send_btn)
+
+ # put all layouts together
+ main_frame = QtWidgets.QVBoxLayout(self.window)
+ main_frame.setMargin(20)
+ main_frame.addLayout(prop_main_layout)
+ main_frame.addWidget(self.tree)
+ main_frame.addLayout(hbox)
+
+ def _set_sequence_params(self):
+ for select in self.selection:
+ self.seq_height = select.height
+ self.seq_width = select.width
+ self.fps = float(str(select.frame_rate)[:-4])
+ break
+
+ def _create_task_type_widget(self, cfg_d):
+ print(self.project_entity)
+ self.task_types = ftrack_lib.get_project_task_types(
+ self.project_entity)
+
+ self.task_type_label = uiwidgets.FlameLabel(
+ 'Create Task (type)', 'normal', self.window)
+ self.task_type_input = uiwidgets.FlamePushButtonMenu(
+ cfg_d["create_task_type"], self.task_types.keys(), self.window)
+
+ def _create_project_widget(self):
+ import flame
+ # get project name from flame current project
+ self.project_name = flame.project.current_project.name
+
+ # get project from ftrack -
+ # ftrack project name has to be the same as flame project!
+ query = 'Project where full_name is "{}"'.format(self.project_name)
+
+ # globally used variables
+ self.project_entity = self.session.query(query).first()
+
+ self.project_selector_enabled = bool(not self.project_entity)
+
+ if self.project_selector_enabled:
+ self.all_projects = self.session.query(
+ "Project where status is active").all()
+ self.project_entity = self.all_projects[0]
+ project_names = [p["full_name"] for p in self.all_projects]
+ self.all_task_types = {
+ p["full_name"]: ftrack_lib.get_project_task_types(p).keys()
+ for p in self.all_projects
+ }
+ self.project_select_label = uiwidgets.FlameLabel(
+ 'Select Ftrack project', 'normal', self.window)
+ self.project_select_input = uiwidgets.FlamePushButtonMenu(
+ self.project_entity["full_name"], project_names, self.window)
+ self.project_select_input.selection_changed.connect(
+ self._on_project_changed)
+
+ def _create_tree_widget(self):
+ ordered_column_labels = self.columns.keys()
+ for _name, _value in self.columns.items():
+ ordered_column_labels.pop(_value["order"])
+ ordered_column_labels.insert(_value["order"], _name)
+
+ self.tree = uiwidgets.FlameTreeWidget(
+ ordered_column_labels, self.window)
+
+ # Allow multiple items in tree to be selected
+ self.tree.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection)
+
+ # Set tree column width
+ for _name, _val in self.columns.items():
+ self.tree.setColumnWidth(
+ _val["order"],
+ _val["columnWidth"]
+ )
+
+ # Prevent weird characters when shrinking tree columns
+ self.tree.setTextElideMode(QtCore.Qt.ElideNone)
+
+ def _resolve_project_entity(self):
+ if self.project_selector_enabled:
+ selected_project_name = self.project_select_input.text()
+ self.project_entity = next(
+ (p for p in self.all_projects
+ if p["full_name"] in selected_project_name),
+ None
+ )
+
+ def _save_ui_state_to_cfg(self):
+ _cfg_data_back = {
+ "shot_name_template": self.shot_name_template_input.text(),
+ "workfile_start_frame": self.start_frame_input.text(),
+ "shot_handles": self.handles_input.text(),
+ "hierarchy_template": self.hierarchy_template_input.text(),
+ "create_task_type": self.task_type_input.text()
+ }
+
+ # add cfg data back to settings.ini
+ app_utils.set_config(_cfg_data_back, "main")
+
+ def _send_to_ftrack(self):
+ # resolve active project and add it to self.project_entity
+ self._resolve_project_entity()
+ self._save_ui_state_to_cfg()
+
+ # get hanldes from gui input
+ handles = self.handles_input.text()
+
+ # get frame start from gui input
+ frame_start = int(self.start_frame_input.text())
+
+ # get task type from gui input
+ task_type = self.task_type_input.text()
+
+ # get resolution from gui inputs
+ fps = self.fps_input.text()
+
+ entity_operator = ftrack_lib.FtrackEntityOperator(
+ self.session, self.project_entity)
+ component_creator = ftrack_lib.FtrackComponentCreator(self.session)
+
+ if not self.temp_data_dir:
+ self.window.hide()
+ self.temp_data_dir = component_creator.generate_temp_data(
+ self.selection,
+ {
+ "nbHandles": handles
+ }
+ )
+ self.window.show()
+
+ # collect generated files to list data for farther use
+ component_creator.collect_generated_data(self.temp_data_dir)
+
+ # Get all selected items from treewidget
+ for item in self.tree.selectedItems():
+ # frame ranges
+ frame_duration = int(item.text(2))
+ frame_end = frame_start + frame_duration
+
+ # description
+ shot_description = item.text(3)
+ task_description = item.text(4)
+
+ # other
+ sequence_name = item.text(0)
+ shot_name = item.text(1)
+
+ thumb_fp = component_creator.get_thumb_path(shot_name)
+ video_fp = component_creator.get_video_path(shot_name)
+
+ print("processed comps: {}".format(self.processed_components))
+ print("processed thumb_fp: {}".format(thumb_fp))
+
+ processed = False
+ if thumb_fp not in self.processed_components:
+ self.processed_components.append(thumb_fp)
+ else:
+ processed = True
+
+ print("processed: {}".format(processed))
+
+ # populate full shot info
+ shot_attributes = {
+ "sequence": sequence_name,
+ "shot": shot_name,
+ "task": task_type
+ }
+
+ # format shot name template
+ _shot_name = self.shot_name_template_input.text().format(
+ **shot_attributes)
+
+ # format hierarchy template
+ _hierarchy_text = self.hierarchy_template_input.text().format(
+ **shot_attributes)
+ print(_hierarchy_text)
+
+ # solve parents
+ parents = entity_operator.create_parents(_hierarchy_text)
+ print(parents)
+
+ # obtain shot parents entities
+ _parent = None
+ for _name, _type in parents:
+ p_entity = entity_operator.get_ftrack_entity(
+ self.session,
+ _type,
+ _name,
+ _parent
+ )
+ print(p_entity)
+ _parent = p_entity
+
+ # obtain shot ftrack entity
+ f_s_entity = entity_operator.get_ftrack_entity(
+ self.session,
+ "Shot",
+ _shot_name,
+ _parent
+ )
+ print("Shot entity is: {}".format(f_s_entity))
+
+ if not processed:
+ # first create thumbnail and get version entity
+ assetversion_entity = component_creator.create_comonent(
+ f_s_entity, {
+ "file_path": thumb_fp
+ }
+ )
+
+ # secondly add video to version entity
+ component_creator.create_comonent(
+ f_s_entity, {
+ "file_path": video_fp,
+ "duration": frame_duration,
+ "handles": int(handles),
+ "fps": float(fps)
+ }, assetversion_entity
+ )
+
+ # create custom attributtes
+ custom_attrs = {
+ "frameStart": frame_start,
+ "frameEnd": frame_end,
+ "handleStart": int(handles),
+ "handleEnd": int(handles),
+ "resolutionWidth": int(self.width_input.text()),
+ "resolutionHeight": int(self.height_input.text()),
+ "pixelAspect": float(self.pixel_aspect_input.text()),
+ "fps": float(fps)
+ }
+
+ # update custom attributes on shot entity
+ for key in custom_attrs:
+ f_s_entity['custom_attributes'][key] = custom_attrs[key]
+
+ task_entity = entity_operator.create_task(
+ task_type, self.task_types, f_s_entity)
+
+ # Create notes.
+ user = self.session.query(
+ "User where username is \"{}\"".format(self.session.api_user)
+ ).first()
+
+ f_s_entity.create_note(shot_description, author=user)
+
+ if task_description:
+ task_entity.create_note(task_description, user)
+
+ entity_operator.commit()
+
+ component_creator.close()
+
+ def _fix_resolution(self):
+ # Center window in linux
+ resolution = QtWidgets.QDesktopWidget().screenGeometry()
+ self.window.move(
+ (resolution.width() / 2) - (self.window.frameSize().width() / 2),
+ (resolution.height() / 2) - (self.window.frameSize().height() / 2))
+
+ def _on_project_changed(self):
+ task_types = self.all_task_types[self.project_name]
+ self.task_type_input.set_menu_options(task_types)
+
+ def _timeline_info(self):
+ # identificar as informacoes dos segmentos na timeline
+ for sequence in self.selection:
+ frame_rate = float(str(sequence.frame_rate)[:-4])
+ for ver in sequence.versions:
+ for tracks in ver.tracks:
+ for segment in tracks.segments:
+ print(segment.attributes)
+ if str(segment.name)[1:-1] == "":
+ continue
+ # get clip frame duration
+ record_duration = str(segment.record_duration)[1:-1]
+ clip_duration = app_utils.timecode_to_frames(
+ record_duration, frame_rate)
+
+ # populate shot source metadata
+ shot_description = ""
+ for attr in ["tape_name", "source_name", "head",
+ "tail", "file_path"]:
+ if not hasattr(segment, attr):
+ continue
+ _value = getattr(segment, attr)
+ _label = attr.replace("_", " ").capitalize()
+ row = "{}: {}\n".format(_label, _value)
+ shot_description += row
+
+ # Add timeline segment to tree
+ QtWidgets.QTreeWidgetItem(self.tree, [
+ str(sequence.name)[1:-1], # seq
+ str(segment.name)[1:-1], # shot
+ str(clip_duration), # clip duration
+ shot_description, # shot description
+ str(segment.comment)[1:-1] # task description
+ ]).setFlags(
+ QtCore.Qt.ItemIsEditable
+ | QtCore.Qt.ItemIsEnabled
+ | QtCore.Qt.ItemIsSelectable
+ )
+
+ # Select top item in tree
+ self.tree.setCurrentItem(self.tree.topLevelItem(0))
+
+ def select_all(self, ):
+ self.tree.selectAll()
+
+ def clear_temp_data(self):
+ import shutil
+
+ self.processed_components = []
+
+ if self.temp_data_dir:
+ shutil.rmtree(self.temp_data_dir)
+ self.temp_data_dir = None
+ print("All Temp data were destroied ...")
+
+ def close(self):
+ self._save_ui_state_to_cfg()
+ self.session.close()
diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/uiwidgets.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/uiwidgets.py
new file mode 100644
index 0000000000..0d4807a4ea
--- /dev/null
+++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/uiwidgets.py
@@ -0,0 +1,212 @@
+from PySide2 import QtWidgets, QtCore
+
+
+class FlameLabel(QtWidgets.QLabel):
+ """
+ Custom Qt Flame Label Widget
+
+ For different label looks set label_type as:
+ 'normal', 'background', or 'outline'
+
+ To use:
+
+ label = FlameLabel('Label Name', 'normal', window)
+ """
+
+ def __init__(self, label_name, label_type, parent_window, *args, **kwargs):
+ super(FlameLabel, self).__init__(*args, **kwargs)
+
+ self.setText(label_name)
+ self.setParent(parent_window)
+ self.setMinimumSize(130, 28)
+ self.setMaximumHeight(28)
+ self.setFocusPolicy(QtCore.Qt.NoFocus)
+
+ # Set label stylesheet based on label_type
+
+ if label_type == 'normal':
+ self.setStyleSheet(
+ 'QLabel {color: #9a9a9a; border-bottom: 1px inset #282828; font: 14px "Discreet"}' # noqa
+ 'QLabel:disabled {color: #6a6a6a}'
+ )
+ elif label_type == 'background':
+ self.setAlignment(QtCore.Qt.AlignCenter)
+ self.setStyleSheet(
+ 'color: #9a9a9a; background-color: #393939; font: 14px "Discreet"' # noqa
+ )
+ elif label_type == 'outline':
+ self.setAlignment(QtCore.Qt.AlignCenter)
+ self.setStyleSheet(
+ 'color: #9a9a9a; background-color: #212121; border: 1px solid #404040; font: 14px "Discreet"' # noqa
+ )
+
+
+class FlameLineEdit(QtWidgets.QLineEdit):
+ """
+ Custom Qt Flame Line Edit Widget
+
+ Main window should include this:
+ window.setFocusPolicy(QtCore.Qt.StrongFocus)
+
+ To use:
+
+ line_edit = FlameLineEdit('Some text here', window)
+ """
+
+ def __init__(self, text, parent_window, *args, **kwargs):
+ super(FlameLineEdit, self).__init__(*args, **kwargs)
+
+ self.setText(text)
+ self.setParent(parent_window)
+ self.setMinimumHeight(28)
+ self.setMinimumWidth(110)
+ self.setStyleSheet(
+ 'QLineEdit {color: #9a9a9a; background-color: #373e47; selection-color: #262626; selection-background-color: #b8b1a7; font: 14px "Discreet"}' # noqa
+ 'QLineEdit:focus {background-color: #474e58}' # noqa
+ 'QLineEdit:disabled {color: #6a6a6a; background-color: #373737}'
+ )
+
+
+class FlameTreeWidget(QtWidgets.QTreeWidget):
+ """
+ Custom Qt Flame Tree Widget
+
+ To use:
+
+ tree_headers = ['Header1', 'Header2', 'Header3', 'Header4']
+ tree = FlameTreeWidget(tree_headers, window)
+ """
+
+ def __init__(self, tree_headers, parent_window, *args, **kwargs):
+ super(FlameTreeWidget, self).__init__(*args, **kwargs)
+
+ self.setMinimumWidth(1000)
+ self.setMinimumHeight(300)
+ self.setSortingEnabled(True)
+ self.sortByColumn(0, QtCore.Qt.AscendingOrder)
+ self.setAlternatingRowColors(True)
+ self.setFocusPolicy(QtCore.Qt.NoFocus)
+ self.setStyleSheet(
+ 'QTreeWidget {color: #9a9a9a; background-color: #2a2a2a; alternate-background-color: #2d2d2d; font: 14px "Discreet"}' # noqa
+ 'QTreeWidget::item:selected {color: #d9d9d9; background-color: #474747; border: 1px solid #111111}' # noqa
+ 'QHeaderView {color: #9a9a9a; background-color: #393939; font: 14px "Discreet"}' # noqa
+ 'QTreeWidget::item:selected {selection-background-color: #111111}'
+ 'QMenu {color: #9a9a9a; background-color: #24303d; font: 14px "Discreet"}' # noqa
+ 'QMenu::item:selected {color: #d9d9d9; background-color: #3a4551}'
+ )
+ self.verticalScrollBar().setStyleSheet('color: #818181')
+ self.horizontalScrollBar().setStyleSheet('color: #818181')
+ self.setHeaderLabels(tree_headers)
+
+
+class FlameButton(QtWidgets.QPushButton):
+ """
+ Custom Qt Flame Button Widget
+
+ To use:
+
+ button = FlameButton('Button Name', do_this_when_pressed, window)
+ """
+
+ def __init__(self, button_name, do_when_pressed, parent_window,
+ *args, **kwargs):
+ super(FlameButton, self).__init__(*args, **kwargs)
+
+ self.setText(button_name)
+ self.setParent(parent_window)
+ self.setMinimumSize(QtCore.QSize(110, 28))
+ self.setMaximumSize(QtCore.QSize(110, 28))
+ self.setFocusPolicy(QtCore.Qt.NoFocus)
+ self.clicked.connect(do_when_pressed)
+ self.setStyleSheet(
+ 'QPushButton {color: #9a9a9a; background-color: #424142; border-top: 1px inset #555555; border-bottom: 1px inset black; font: 14px "Discreet"}' # noqa
+ 'QPushButton:pressed {color: #d9d9d9; background-color: #4f4f4f; border-top: 1px inset #666666; font: italic}' # noqa
+ 'QPushButton:disabled {color: #747474; background-color: #353535; border-top: 1px solid #444444; border-bottom: 1px solid #242424}' # noqa
+ )
+
+
+class FlamePushButton(QtWidgets.QPushButton):
+ """
+ Custom Qt Flame Push Button Widget
+
+ To use:
+
+ pushbutton = FlamePushButton(' Button Name', True_or_False, window)
+ """
+
+ def __init__(self, button_name, button_checked, parent_window,
+ *args, **kwargs):
+ super(FlamePushButton, self).__init__(*args, **kwargs)
+
+ self.setText(button_name)
+ self.setParent(parent_window)
+ self.setCheckable(True)
+ self.setChecked(button_checked)
+ self.setMinimumSize(155, 28)
+ self.setMaximumSize(155, 28)
+ self.setFocusPolicy(QtCore.Qt.NoFocus)
+ self.setStyleSheet(
+ 'QPushButton {color: #9a9a9a; background-color: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, stop: .93 #424142, stop: .94 #2e3b48); text-align: left; border-top: 1px inset #555555; border-bottom: 1px inset black; font: 14px "Discreet"}' # noqa
+ 'QPushButton:checked {color: #d9d9d9; background-color: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, stop: .93 #4f4f4f, stop: .94 #5a7fb4); font: italic; border: 1px inset black; border-bottom: 1px inset #404040; border-right: 1px inset #404040}' # noqa
+ 'QPushButton:disabled {color: #6a6a6a; background-color: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, stop: .93 #383838, stop: .94 #353535); font: light; border-top: 1px solid #575757; border-bottom: 1px solid #242424; border-right: 1px solid #353535; border-left: 1px solid #353535}' # noqa
+ 'QToolTip {color: black; background-color: #ffffde; border: black solid 1px}' # noqa
+ )
+
+
+class FlamePushButtonMenu(QtWidgets.QPushButton):
+ """
+ Custom Qt Flame Menu Push Button Widget
+
+ To use:
+
+ push_button_menu_options = ['Item 1', 'Item 2', 'Item 3', 'Item 4']
+ menu_push_button = FlamePushButtonMenu('push_button_name',
+ push_button_menu_options, window)
+
+ or
+
+ push_button_menu_options = ['Item 1', 'Item 2', 'Item 3', 'Item 4']
+ menu_push_button = FlamePushButtonMenu(push_button_menu_options[0],
+ push_button_menu_options, window)
+ """
+ selection_changed = QtCore.Signal(str)
+
+ def __init__(self, button_name, menu_options, parent_window,
+ *args, **kwargs):
+ super(FlamePushButtonMenu, self).__init__(*args, **kwargs)
+
+ self.setParent(parent_window)
+ self.setMinimumHeight(28)
+ self.setMinimumWidth(110)
+ self.setFocusPolicy(QtCore.Qt.NoFocus)
+ self.setStyleSheet(
+ 'QPushButton {color: #9a9a9a; background-color: #24303d; font: 14px "Discreet"}' # noqa
+ 'QPushButton:disabled {color: #747474; background-color: #353535; border-top: 1px solid #444444; border-bottom: 1px solid #242424}' # noqa
+ )
+
+ pushbutton_menu = QtWidgets.QMenu(parent_window)
+ pushbutton_menu.setFocusPolicy(QtCore.Qt.NoFocus)
+ pushbutton_menu.setStyleSheet(
+ 'QMenu {color: #9a9a9a; background-color:#24303d; font: 14px "Discreet"}' # noqa
+ 'QMenu::item:selected {color: #d9d9d9; background-color: #3a4551}'
+ )
+
+ self._pushbutton_menu = pushbutton_menu
+ self.setMenu(pushbutton_menu)
+ self.set_menu_options(menu_options, button_name)
+
+ def set_menu_options(self, menu_options, current_option=None):
+ self._pushbutton_menu.clear()
+ current_option = current_option or menu_options[0]
+
+ for option in menu_options:
+ action = self._pushbutton_menu.addAction(option)
+ action.triggered.connect(self._on_action_trigger)
+
+ if current_option is not None:
+ self.setText(current_option)
+
+ def _on_action_trigger(self):
+ action = self.sender()
+ self.setText(action.text())
+ self.selection_changed.emit(action.text())
diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/openpype_flame_to_ftrack.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/openpype_flame_to_ftrack.py
new file mode 100644
index 0000000000..688b8b6ae3
--- /dev/null
+++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/openpype_flame_to_ftrack.py
@@ -0,0 +1,38 @@
+from __future__ import print_function
+
+import os
+import sys
+
+SCRIPT_DIR = os.path.dirname(__file__)
+PACKAGE_DIR = os.path.join(SCRIPT_DIR, "modules")
+sys.path.append(PACKAGE_DIR)
+
+
+def flame_panel_executor(selection):
+ if "panel_app" in sys.modules.keys():
+ print("panel_app module is already loaded")
+ del sys.modules["panel_app"]
+ print("panel_app module removed from sys.modules")
+
+ import panel_app
+ panel_app.FlameToFtrackPanel(selection)
+
+
+def scope_sequence(selection):
+ import flame
+ return any(isinstance(item, flame.PySequence) for item in selection)
+
+
+def get_media_panel_custom_ui_actions():
+ return [
+ {
+ "name": "OpenPype: Ftrack",
+ "actions": [
+ {
+ "name": "Create Shots",
+ "isVisible": scope_sequence,
+ "execute": flame_panel_executor
+ }
+ ]
+ }
+ ]
diff --git a/openpype/hosts/flame/utility_scripts/openpype_in_flame.py b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py
similarity index 100%
rename from openpype/hosts/flame/utility_scripts/openpype_in_flame.py
rename to openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py
diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py
index a750046362..201c7d2fac 100644
--- a/openpype/hosts/flame/api/utils.py
+++ b/openpype/hosts/flame/api/utils.py
@@ -27,6 +27,7 @@ def _sync_utility_scripts(env=None):
fsd_paths = [os.path.join(
HOST_DIR,
+ "api",
"utility_scripts"
)]
diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py
index 368a70f395..159fb37410 100644
--- a/openpype/hosts/flame/hooks/pre_flame_setup.py
+++ b/openpype/hosts/flame/hooks/pre_flame_setup.py
@@ -2,6 +2,7 @@ import os
import json
import tempfile
import contextlib
+import socket
from openpype.lib import (
PreLaunchHook, get_openpype_username)
from openpype.hosts import flame as opflame
@@ -21,7 +22,7 @@ class FlamePrelaunch(PreLaunchHook):
flame_python_exe = "/opt/Autodesk/python/2021/bin/python2.7"
wtc_script_path = os.path.join(
- opflame.HOST_DIR, "scripts", "wiretap_com.py")
+ opflame.HOST_DIR, "api", "scripts", "wiretap_com.py")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -32,6 +33,7 @@ class FlamePrelaunch(PreLaunchHook):
"""Hook entry method."""
project_doc = self.data["project_doc"]
user_name = get_openpype_username()
+ hostname = socket.gethostname() # not returning wiretap host name
self.log.debug("Collected user \"{}\"".format(user_name))
self.log.info(pformat(project_doc))
@@ -53,11 +55,12 @@ class FlamePrelaunch(PreLaunchHook):
"FieldDominance": "PROGRESSIVE"
}
+
data_to_script = {
# from settings
- "host_name": "localhost",
- "volume_name": "stonefs",
- "group_name": "staff",
+ "host_name": os.getenv("FLAME_WIRETAP_HOSTNAME") or hostname,
+ "volume_name": os.getenv("FLAME_WIRETAP_VOLUME"),
+ "group_name": os.getenv("FLAME_WIRETAP_GROUP"),
"color_policy": "ACES 1.1",
# from project
diff --git a/openpype/hosts/hiero/__init__.py b/openpype/hosts/hiero/__init__.py
index 1781f808e2..15bd10fdb0 100644
--- a/openpype/hosts/hiero/__init__.py
+++ b/openpype/hosts/hiero/__init__.py
@@ -6,7 +6,7 @@ def add_implementation_envs(env, _app):
# Add requirements to HIERO_PLUGIN_PATH
pype_root = os.environ["OPENPYPE_REPOS_ROOT"]
new_hiero_paths = [
- os.path.join(pype_root, "openpype", "hosts", "hiero", "startup")
+ os.path.join(pype_root, "openpype", "hosts", "hiero", "api", "startup")
]
old_hiero_path = env.get("HIERO_PLUGIN_PATH") or ""
for path in old_hiero_path.split(os.pathsep):
diff --git a/openpype/hosts/hiero/api/__init__.py b/openpype/hosts/hiero/api/__init__.py
index 8d0105ae5f..f3c32b268c 100644
--- a/openpype/hosts/hiero/api/__init__.py
+++ b/openpype/hosts/hiero/api/__init__.py
@@ -23,6 +23,7 @@ from .pipeline import (
from .lib import (
pype_tag_name,
+ flatten,
get_track_items,
get_current_project,
get_current_sequence,
@@ -75,6 +76,7 @@ __all__ = [
# Lib functions
"pype_tag_name",
+ "flatten",
"get_track_items",
"get_current_project",
"get_current_sequence",
diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py
index 21b65e5c96..9a22d8cf27 100644
--- a/openpype/hosts/hiero/api/lib.py
+++ b/openpype/hosts/hiero/api/lib.py
@@ -4,6 +4,7 @@ Host specific functions where host api is connected
import os
import re
import sys
+import platform
import ast
import shutil
import hiero
@@ -12,7 +13,6 @@ import avalon.api as avalon
import avalon.io
from openpype.api import (Logger, Anatomy, get_anatomy_settings)
from . import tags
-from compiler.ast import flatten
try:
from PySide.QtCore import QFile, QTextStream
@@ -38,6 +38,14 @@ self.default_bin_name = "openpypeBin"
AVALON_CONFIG = os.getenv("AVALON_CONFIG", "pype")
+def flatten(_list):
+ for item in _list:
+ if isinstance(item, (list, tuple)):
+ for sub_item in flatten(item):
+ yield sub_item
+ else:
+ yield item
+
def get_current_project(remove_untitled=False):
projects = flatten(hiero.core.projects())
if not remove_untitled:
@@ -250,7 +258,7 @@ def set_track_item_pype_tag(track_item, data=None):
Returns:
hiero.core.Tag
"""
- data = data or dict()
+ data = data or {}
# basic Tag's attribute
tag_data = {
@@ -284,7 +292,7 @@ def get_track_item_pype_data(track_item):
Returns:
dict: data found on pype tag
"""
- data = dict()
+ data = {}
# get pype data tag from track item
tag = get_track_item_pype_tag(track_item)
@@ -299,8 +307,20 @@ def get_track_item_pype_data(track_item):
try:
# capture exceptions which are related to strings only
- value = ast.literal_eval(v)
- except (ValueError, SyntaxError):
+ if re.match(r"^[\d]+$", v):
+ value = int(v)
+ elif re.match(r"^True$", v):
+ value = True
+ elif re.match(r"^False$", v):
+ value = False
+ elif re.match(r"^None$", v):
+ value = None
+ elif re.match(r"^[\w\d_]+$", v):
+ value = v
+ else:
+ value = ast.literal_eval(v)
+ except (ValueError, SyntaxError) as msg:
+ log.warning(msg)
value = v
data.update({key: value})
@@ -729,9 +749,14 @@ def get_selected_track_items(sequence=None):
def set_selected_track_items(track_items_list, sequence=None):
_sequence = sequence or get_current_sequence()
+ # make sure only trackItems are in list selection
+ only_track_items = [
+ i for i in track_items_list
+ if isinstance(i, hiero.core.TrackItem)]
+
# Getting selection
timeline_editor = hiero.ui.getTimelineEditor(_sequence)
- return timeline_editor.setSelection(track_items_list)
+ return timeline_editor.setSelection(only_track_items)
def _read_doc_from_path(path):
@@ -759,6 +784,13 @@ def _set_hrox_project_knobs(doc, **knobs):
# set attributes to Project Tag
proj_elem = doc.documentElement().firstChildElement("Project")
for k, v in knobs.items():
+ if "ocioconfigpath" in k:
+ paths_to_format = v[platform.system().lower()]
+ for _path in paths_to_format:
+ v = _path.format(**os.environ)
+ if not os.path.exists(v):
+ continue
+ log.debug("Project colorspace knob `{}` was set to `{}`".format(k, v))
if isinstance(v, dict):
continue
proj_elem.setAttribute(str(k), v)
diff --git a/openpype/modules/default_modules/log_viewer/tray/__init__.py b/openpype/hosts/hiero/api/otio/__init__.py
similarity index 100%
rename from openpype/modules/default_modules/log_viewer/tray/__init__.py
rename to openpype/hosts/hiero/api/otio/__init__.py
diff --git a/openpype/hosts/hiero/otio/hiero_export.py b/openpype/hosts/hiero/api/otio/hiero_export.py
similarity index 98%
rename from openpype/hosts/hiero/otio/hiero_export.py
rename to openpype/hosts/hiero/api/otio/hiero_export.py
index af4322e3d9..abf510403e 100644
--- a/openpype/hosts/hiero/otio/hiero_export.py
+++ b/openpype/hosts/hiero/api/otio/hiero_export.py
@@ -5,7 +5,6 @@ import os
import re
import sys
import ast
-from compiler.ast import flatten
import opentimelineio as otio
from . import utils
import hiero.core
@@ -29,6 +28,15 @@ self.timeline = None
self.include_tags = True
+def flatten(_list):
+ for item in _list:
+ if isinstance(item, (list, tuple)):
+ for sub_item in flatten(item):
+ yield sub_item
+ else:
+ yield item
+
+
def get_current_hiero_project(remove_untitled=False):
projects = flatten(hiero.core.projects())
if not remove_untitled:
@@ -74,13 +82,11 @@ def create_time_effects(otio_clip, track_item):
otio_effect = otio.schema.LinearTimeWarp()
otio_effect.name = "Speed"
otio_effect.time_scalar = speed
- otio_effect.metadata = {}
# freeze frame effect
if speed == 0.:
otio_effect = otio.schema.FreezeFrame()
otio_effect.name = "FreezeFrame"
- otio_effect.metadata = {}
if otio_effect:
# add otio effect to clip effects
diff --git a/openpype/hosts/hiero/otio/hiero_import.py b/openpype/hosts/hiero/api/otio/hiero_import.py
similarity index 100%
rename from openpype/hosts/hiero/otio/hiero_import.py
rename to openpype/hosts/hiero/api/otio/hiero_import.py
diff --git a/openpype/hosts/hiero/otio/utils.py b/openpype/hosts/hiero/api/otio/utils.py
similarity index 100%
rename from openpype/hosts/hiero/otio/utils.py
rename to openpype/hosts/hiero/api/otio/utils.py
diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py
index 75d1c1b18f..2bbb1df8c1 100644
--- a/openpype/hosts/hiero/api/plugin.py
+++ b/openpype/hosts/hiero/api/plugin.py
@@ -191,7 +191,7 @@ class CreatorWidget(QtWidgets.QDialog):
content_layout = content_layout or self.content_layout[-1]
# fix order of process by defined order value
- ordered_keys = data.keys()
+ ordered_keys = list(data.keys())
for k, v in data.items():
try:
# try removing a key from index which should
diff --git a/openpype/hosts/hiero/startup/HieroPlayer/PlayerPresets.hrox b/openpype/hosts/hiero/api/startup/HieroPlayer/PlayerPresets.hrox
similarity index 100%
rename from openpype/hosts/hiero/startup/HieroPlayer/PlayerPresets.hrox
rename to openpype/hosts/hiero/api/startup/HieroPlayer/PlayerPresets.hrox
diff --git a/openpype/hosts/hiero/startup/Icons/1_add_handles_end.png b/openpype/hosts/hiero/api/startup/Icons/1_add_handles_end.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/1_add_handles_end.png
rename to openpype/hosts/hiero/api/startup/Icons/1_add_handles_end.png
diff --git a/openpype/hosts/hiero/startup/Icons/2_add_handles.png b/openpype/hosts/hiero/api/startup/Icons/2_add_handles.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/2_add_handles.png
rename to openpype/hosts/hiero/api/startup/Icons/2_add_handles.png
diff --git a/openpype/hosts/hiero/startup/Icons/3D.png b/openpype/hosts/hiero/api/startup/Icons/3D.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/3D.png
rename to openpype/hosts/hiero/api/startup/Icons/3D.png
diff --git a/openpype/hosts/hiero/startup/Icons/3_add_handles_start.png b/openpype/hosts/hiero/api/startup/Icons/3_add_handles_start.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/3_add_handles_start.png
rename to openpype/hosts/hiero/api/startup/Icons/3_add_handles_start.png
diff --git a/openpype/hosts/hiero/startup/Icons/4_2D.png b/openpype/hosts/hiero/api/startup/Icons/4_2D.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/4_2D.png
rename to openpype/hosts/hiero/api/startup/Icons/4_2D.png
diff --git a/openpype/hosts/hiero/startup/Icons/edit.png b/openpype/hosts/hiero/api/startup/Icons/edit.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/edit.png
rename to openpype/hosts/hiero/api/startup/Icons/edit.png
diff --git a/openpype/hosts/hiero/startup/Icons/fusion.png b/openpype/hosts/hiero/api/startup/Icons/fusion.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/fusion.png
rename to openpype/hosts/hiero/api/startup/Icons/fusion.png
diff --git a/openpype/hosts/hiero/startup/Icons/hierarchy.png b/openpype/hosts/hiero/api/startup/Icons/hierarchy.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/hierarchy.png
rename to openpype/hosts/hiero/api/startup/Icons/hierarchy.png
diff --git a/openpype/hosts/hiero/startup/Icons/houdini.png b/openpype/hosts/hiero/api/startup/Icons/houdini.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/houdini.png
rename to openpype/hosts/hiero/api/startup/Icons/houdini.png
diff --git a/openpype/hosts/hiero/startup/Icons/layers.psd b/openpype/hosts/hiero/api/startup/Icons/layers.psd
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/layers.psd
rename to openpype/hosts/hiero/api/startup/Icons/layers.psd
diff --git a/openpype/hosts/hiero/startup/Icons/lense.png b/openpype/hosts/hiero/api/startup/Icons/lense.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/lense.png
rename to openpype/hosts/hiero/api/startup/Icons/lense.png
diff --git a/openpype/hosts/hiero/startup/Icons/lense1.png b/openpype/hosts/hiero/api/startup/Icons/lense1.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/lense1.png
rename to openpype/hosts/hiero/api/startup/Icons/lense1.png
diff --git a/openpype/hosts/hiero/startup/Icons/maya.png b/openpype/hosts/hiero/api/startup/Icons/maya.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/maya.png
rename to openpype/hosts/hiero/api/startup/Icons/maya.png
diff --git a/openpype/hosts/hiero/startup/Icons/nuke.png b/openpype/hosts/hiero/api/startup/Icons/nuke.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/nuke.png
rename to openpype/hosts/hiero/api/startup/Icons/nuke.png
diff --git a/openpype/hosts/hiero/startup/Icons/pype_icon.png b/openpype/hosts/hiero/api/startup/Icons/pype_icon.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/pype_icon.png
rename to openpype/hosts/hiero/api/startup/Icons/pype_icon.png
diff --git a/openpype/hosts/hiero/startup/Icons/resolution.png b/openpype/hosts/hiero/api/startup/Icons/resolution.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/resolution.png
rename to openpype/hosts/hiero/api/startup/Icons/resolution.png
diff --git a/openpype/hosts/hiero/startup/Icons/resolution.psd b/openpype/hosts/hiero/api/startup/Icons/resolution.psd
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/resolution.psd
rename to openpype/hosts/hiero/api/startup/Icons/resolution.psd
diff --git a/openpype/hosts/hiero/startup/Icons/retiming.png b/openpype/hosts/hiero/api/startup/Icons/retiming.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/retiming.png
rename to openpype/hosts/hiero/api/startup/Icons/retiming.png
diff --git a/openpype/hosts/hiero/startup/Icons/retiming.psd b/openpype/hosts/hiero/api/startup/Icons/retiming.psd
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/retiming.psd
rename to openpype/hosts/hiero/api/startup/Icons/retiming.psd
diff --git a/openpype/hosts/hiero/startup/Icons/review.png b/openpype/hosts/hiero/api/startup/Icons/review.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/review.png
rename to openpype/hosts/hiero/api/startup/Icons/review.png
diff --git a/openpype/hosts/hiero/startup/Icons/review.psd b/openpype/hosts/hiero/api/startup/Icons/review.psd
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/review.psd
rename to openpype/hosts/hiero/api/startup/Icons/review.psd
diff --git a/openpype/hosts/hiero/startup/Icons/volume.png b/openpype/hosts/hiero/api/startup/Icons/volume.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/volume.png
rename to openpype/hosts/hiero/api/startup/Icons/volume.png
diff --git a/openpype/hosts/hiero/startup/Icons/z_layer_bg.png b/openpype/hosts/hiero/api/startup/Icons/z_layer_bg.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/z_layer_bg.png
rename to openpype/hosts/hiero/api/startup/Icons/z_layer_bg.png
diff --git a/openpype/hosts/hiero/startup/Icons/z_layer_fg.png b/openpype/hosts/hiero/api/startup/Icons/z_layer_fg.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/z_layer_fg.png
rename to openpype/hosts/hiero/api/startup/Icons/z_layer_fg.png
diff --git a/openpype/hosts/hiero/startup/Icons/z_layer_main.png b/openpype/hosts/hiero/api/startup/Icons/z_layer_main.png
similarity index 100%
rename from openpype/hosts/hiero/startup/Icons/z_layer_main.png
rename to openpype/hosts/hiero/api/startup/Icons/z_layer_main.png
diff --git a/openpype/hosts/hiero/startup/Python/Startup/SpreadsheetExport.py b/openpype/hosts/hiero/api/startup/Python/Startup/SpreadsheetExport.py
similarity index 86%
rename from openpype/hosts/hiero/startup/Python/Startup/SpreadsheetExport.py
rename to openpype/hosts/hiero/api/startup/Python/Startup/SpreadsheetExport.py
index 3adea8051c..9c919e7cb4 100644
--- a/openpype/hosts/hiero/startup/Python/Startup/SpreadsheetExport.py
+++ b/openpype/hosts/hiero/api/startup/Python/Startup/SpreadsheetExport.py
@@ -18,7 +18,7 @@ except:
### Magic Widget Finding Methods - This stuff crawls all the PySide widgets, looking for an answer
def findWidget(w):
global foundryWidgets
- if 'Foundry' in w.metaObject().className():
+ if "Foundry" in w.metaObject().className():
foundryWidgets += [w]
for c in w.children():
@@ -49,7 +49,7 @@ def activeSpreadsheetTreeView():
Does some PySide widget Magic to detect the Active Spreadsheet TreeView.
"""
spreadsheetViews = getFoundryWidgetsWithClassName(
- filter='SpreadsheetTreeView')
+ filter="SpreadsheetTreeView")
for spreadSheet in spreadsheetViews:
if spreadSheet.hasFocus():
activeSpreadSheet = spreadSheet
@@ -77,23 +77,23 @@ class SpreadsheetExportCSVAction(QAction):
spreadsheetTreeView = activeSpreadsheetTreeView()
if not spreadsheetTreeView:
- return 'Unable to detect the active TreeView.'
+ return "Unable to detect the active TreeView."
seq = hiero.ui.activeView().sequence()
if not seq:
- print 'Unable to detect the active Sequence from the activeView.'
+ print("Unable to detect the active Sequence from the activeView.")
return
# The data model of the QTreeView
model = spreadsheetTreeView.model()
- csvSavePath = os.path.join(QDir.homePath(), 'Desktop',
- seq.name() + '.csv')
+ csvSavePath = os.path.join(QDir.homePath(), "Desktop",
+ seq.name() + ".csv")
savePath, filter = QFileDialog.getSaveFileName(
None,
caption="Export Spreadsheet to .CSV as...",
dir=csvSavePath,
filter="*.csv")
- print 'Saving To: ' + str(savePath)
+ print("Saving To: {}".format(savePath))
# Saving was cancelled...
if len(savePath) == 0:
@@ -101,12 +101,12 @@ class SpreadsheetExportCSVAction(QAction):
# Get the Visible Header Columns from the QTreeView
- #csvHeader = ['Event', 'Status', 'Shot Name', 'Reel', 'Track', 'Speed', 'Src In', 'Src Out','Src Duration', 'Dst In', 'Dst Out', 'Dst Duration', 'Clip', 'Clip Media']
+ #csvHeader = ["Event", "Status", "Shot Name", "Reel", "Track", "Speed", "Src In", "Src Out","Src Duration", "Dst In", "Dst Out", "Dst Duration", "Clip", "Clip Media"]
# Get a CSV writer object
- f = open(savePath, 'w')
+ f = open(savePath, "w")
csvWriter = csv.writer(
- f, delimiter=',', quotechar='|', quoting=csv.QUOTE_MINIMAL)
+ f, delimiter=',', quotechar="|", quoting=csv.QUOTE_MINIMAL)
# This is a list of the Column titles
csvHeader = []
diff --git a/openpype/hosts/hiero/startup/Python/Startup/Startup.py b/openpype/hosts/hiero/api/startup/Python/Startup/Startup.py
similarity index 100%
rename from openpype/hosts/hiero/startup/Python/Startup/Startup.py
rename to openpype/hosts/hiero/api/startup/Python/Startup/Startup.py
diff --git a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportTask.py b/openpype/hosts/hiero/api/startup/Python/Startup/otioexporter/OTIOExportTask.py
similarity index 96%
rename from openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportTask.py
rename to openpype/hosts/hiero/api/startup/Python/Startup/otioexporter/OTIOExportTask.py
index 7e1a8df2dc..e4ce2fe827 100644
--- a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportTask.py
+++ b/openpype/hosts/hiero/api/startup/Python/Startup/otioexporter/OTIOExportTask.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python
# -*- coding: utf-8 -*-
__author__ = "Daniel Flehner Heen"
@@ -9,7 +8,7 @@ import hiero.core
from hiero.core import util
import opentimelineio as otio
-from openpype.hosts.hiero.otio import hiero_export
+from openpype.hosts.hiero.api.otio import hiero_export
class OTIOExportTask(hiero.core.TaskBase):
diff --git a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportUI.py b/openpype/hosts/hiero/api/startup/Python/Startup/otioexporter/OTIOExportUI.py
similarity index 91%
rename from openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportUI.py
rename to openpype/hosts/hiero/api/startup/Python/Startup/otioexporter/OTIOExportUI.py
index 9b83eefedf..af5593e484 100644
--- a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportUI.py
+++ b/openpype/hosts/hiero/api/startup/Python/Startup/otioexporter/OTIOExportUI.py
@@ -1,11 +1,13 @@
-#!/usr/bin/env python
# -*- coding: utf-8 -*-
__author__ = "Daniel Flehner Heen"
__credits__ = ["Jakub Jezek", "Daniel Flehner Heen"]
import hiero.ui
-import OTIOExportTask
+from .OTIOExportTask import (
+ OTIOExportTask,
+ OTIOExportPreset
+)
try:
# Hiero >= 11.x
@@ -20,14 +22,14 @@ except ImportError:
FormLayout = QFormLayout # lint:ok
-from openpype.hosts.hiero.otio import hiero_export
+from openpype.hosts.hiero.api.otio import hiero_export
class OTIOExportUI(hiero.ui.TaskUIBase):
def __init__(self, preset):
"""Initialize"""
hiero.ui.TaskUIBase.__init__(
self,
- OTIOExportTask.OTIOExportTask,
+ OTIOExportTask,
preset,
"OTIO Exporter"
)
@@ -67,6 +69,6 @@ class OTIOExportUI(hiero.ui.TaskUIBase):
hiero.ui.taskUIRegistry.registerTaskUI(
- OTIOExportTask.OTIOExportPreset,
+ OTIOExportPreset,
OTIOExportUI
)
diff --git a/openpype/hosts/hiero/api/startup/Python/Startup/otioexporter/__init__.py b/openpype/hosts/hiero/api/startup/Python/Startup/otioexporter/__init__.py
new file mode 100644
index 0000000000..33d3fc6c59
--- /dev/null
+++ b/openpype/hosts/hiero/api/startup/Python/Startup/otioexporter/__init__.py
@@ -0,0 +1,7 @@
+from .OTIOExportTask import OTIOExportTask
+from .OTIOExportUI import OTIOExportUI
+
+__all__ = [
+ "OTIOExportTask",
+ "OTIOExportUI"
+]
diff --git a/openpype/hosts/hiero/startup/Python/Startup/project_helpers.py b/openpype/hosts/hiero/api/startup/Python/Startup/project_helpers.py
similarity index 81%
rename from openpype/hosts/hiero/startup/Python/Startup/project_helpers.py
rename to openpype/hosts/hiero/api/startup/Python/Startup/project_helpers.py
index 7e274bd0a3..64b5c37d7b 100644
--- a/openpype/hosts/hiero/startup/Python/Startup/project_helpers.py
+++ b/openpype/hosts/hiero/api/startup/Python/Startup/project_helpers.py
@@ -21,7 +21,7 @@ def __trackActiveProjectHandler(event):
global gTrackedActiveProject
selection = event.sender.selection()
binSelection = selection
- if len(binSelection) > 0 and hasattr(binSelection[0], 'project'):
+ if len(binSelection) > 0 and hasattr(binSelection[0], "project"):
proj = binSelection[0].project()
# We only store this if its a valid, active User Project
@@ -30,18 +30,18 @@ def __trackActiveProjectHandler(event):
hiero.core.events.registerInterest(
- 'kSelectionChanged/kBin', __trackActiveProjectHandler)
+ "kSelectionChanged/kBin", __trackActiveProjectHandler)
hiero.core.events.registerInterest(
- 'kSelectionChanged/kTimeline', __trackActiveProjectHandler)
+ "kSelectionChanged/kTimeline", __trackActiveProjectHandler)
hiero.core.events.registerInterest(
- 'kSelectionChanged/Spreadsheet', __trackActiveProjectHandler)
+ "kSelectionChanged/Spreadsheet", __trackActiveProjectHandler)
def activeProject():
"""hiero.ui.activeProject() -> returns the current Project
- Note: There is not technically a notion of a 'active' Project in Hiero/NukeStudio, as it is a multi-project App.
- This method determines what is 'active' by going down the following rules...
+ Note: There is not technically a notion of a "active" Project in Hiero/NukeStudio, as it is a multi-project App.
+ This method determines what is "active" by going down the following rules...
# 1 - If the current Viewer (hiero.ui.currentViewer) contains a Clip or Sequence, this item is assumed to give the active Project
# 2 - If nothing is currently in the Viewer, look to the active View, determine project from active selection
@@ -54,7 +54,7 @@ def activeProject():
# Case 1 : Look for what the current Viewr tells us - this might not be what we want, and relies on hiero.ui.currentViewer() being robust.
cv = hiero.ui.currentViewer().player().sequence()
- if hasattr(cv, 'project'):
+ if hasattr(cv, "project"):
activeProject = cv.project()
else:
# Case 2: We can't determine a project from the current Viewer, so try seeing what's selected in the activeView
@@ -66,16 +66,16 @@ def activeProject():
# Handle the case where nothing is selected in the active view
if len(selection) == 0:
- # It's possible that there is no selection in a Timeline/Spreadsheet, but these views have 'sequence' method, so try that...
+ # It's possible that there is no selection in a Timeline/Spreadsheet, but these views have "sequence" method, so try that...
if isinstance(hiero.ui.activeView(), (hiero.ui.TimelineEditor, hiero.ui.SpreadsheetView)):
activeSequence = activeView.sequence()
- if hasattr(currentItem, 'project'):
+ if hasattr(currentItem, "project"):
activeProject = activeSequence.project()
# The active view has a selection... assume that the first item in the selection has the active Project
else:
currentItem = selection[0]
- if hasattr(currentItem, 'project'):
+ if hasattr(currentItem, "project"):
activeProject = currentItem.project()
# Finally, Cases 3 and 4...
@@ -156,9 +156,14 @@ class SaveAllProjects(QAction):
for proj in allProjects:
try:
proj.save()
- print 'Saved Project: %s to: %s ' % (proj.name(), proj.path())
+ print("Saved Project: {} to: {} ".format(
+ proj.name(), proj.path()
+ ))
except:
- print 'Unable to save Project: %s to: %s. Check file permissions.' % (proj.name(), proj.path())
+ print((
+ "Unable to save Project: {} to: {}. "
+ "Check file permissions.").format(
+ proj.name(), proj.path()))
def eventHandler(self, event):
event.menu.addAction(self)
@@ -190,32 +195,38 @@ class SaveNewProjectVersion(QAction):
v = None
prefix = None
try:
- (prefix, v) = version_get(path, 'v')
- except ValueError, msg:
- print msg
+ (prefix, v) = version_get(path, "v")
+ except ValueError as msg:
+ print(msg)
if (prefix is not None) and (v is not None):
v = int(v)
newPath = version_set(path, prefix, v, v + 1)
try:
proj.saveAs(newPath)
- print 'Saved new project version: %s to: %s ' % (oldName, newPath)
+ print("Saved new project version: {} to: {} ".format(
+ oldName, newPath))
except:
- print 'Unable to save Project: %s. Check file permissions.' % (oldName)
+ print((
+ "Unable to save Project: {}. Check file permissions."
+ ).format(oldName))
else:
newPath = path.replace(".hrox", "_v01.hrox")
answer = nuke.ask(
- '%s does not contain a version number.\nDo you want to save as %s?' % (proj, newPath))
+ "%s does not contain a version number.\nDo you want to save as %s?" % (proj, newPath))
if answer:
try:
proj.saveAs(newPath)
- print 'Saved new project version: %s to: %s ' % (oldName, newPath)
+ print("Saved new project version: {} to: {} ".format(
+ oldName, newPath))
except:
- print 'Unable to save Project: %s. Check file permissions.' % (oldName)
+ print((
+ "Unable to save Project: {}. Check file "
+ "permissions.").format(oldName))
def eventHandler(self, event):
self.selectedProjects = []
- if hasattr(event.sender, 'selection') and event.sender.selection() is not None and len(event.sender.selection()) != 0:
+ if hasattr(event.sender, "selection") and event.sender.selection() is not None and len(event.sender.selection()) != 0:
selection = event.sender.selection()
self.selectedProjects = uniquify(
[item.project() for item in selection])
diff --git a/openpype/hosts/hiero/api/startup/Python/Startup/selection_tracker.py b/openpype/hosts/hiero/api/startup/Python/Startup/selection_tracker.py
new file mode 100644
index 0000000000..a9789cf508
--- /dev/null
+++ b/openpype/hosts/hiero/api/startup/Python/Startup/selection_tracker.py
@@ -0,0 +1,9 @@
+"""Puts the selection project into "hiero.selection"""
+
+import hiero
+
+
+def selectionChanged(event):
+ hiero.selection = event.sender.selection()
+
+hiero.core.events.registerInterest("kSelectionChanged", selectionChanged)
diff --git a/openpype/hosts/hiero/startup/Python/Startup/setFrameRate.py b/openpype/hosts/hiero/api/startup/Python/Startup/setFrameRate.py
similarity index 90%
rename from openpype/hosts/hiero/startup/Python/Startup/setFrameRate.py
rename to openpype/hosts/hiero/api/startup/Python/Startup/setFrameRate.py
index ceb96a6fce..07ae48aef5 100644
--- a/openpype/hosts/hiero/startup/Python/Startup/setFrameRate.py
+++ b/openpype/hosts/hiero/api/startup/Python/Startup/setFrameRate.py
@@ -23,7 +23,7 @@ class SetFrameRateDialog(QDialog):
self._itemSelection = itemSelection
self._frameRateField = QLineEdit()
- self._frameRateField.setToolTip('Enter custom frame rate here.')
+ self._frameRateField.setToolTip("Enter custom frame rate here.")
self._frameRateField.setValidator(QDoubleValidator(1, 99, 3, self))
self._frameRateField.textChanged.connect(self._textChanged)
layout.addRow("Enter fps: ",self._frameRateField)
@@ -35,13 +35,13 @@ class SetFrameRateDialog(QDialog):
self._buttonbox.button(QDialogButtonBox.Ok).setEnabled(False)
layout.addRow("",self._buttonbox)
self.setLayout(layout)
-
+
def _updateOkButtonState(self):
# Cancel is always an option but only enable Ok if there is some text.
currentFramerate = float(self.currentFramerateString())
enableOk = False
enableOk = ((currentFramerate > 0.0) and (currentFramerate <= 250.0))
- print 'enabledOk',enableOk
+ print("enabledOk", enableOk)
self._buttonbox.button(QDialogButtonBox.Ok).setEnabled(enableOk)
def _textChanged(self, newText):
@@ -50,32 +50,32 @@ class SetFrameRateDialog(QDialog):
# Returns the current frame rate as a string
def currentFramerateString(self):
return str(self._frameRateField.text())
-
+
# Presents the Dialog and sets the Frame rate from a selection
def showDialogAndSetFrameRateFromSelection(self):
-
+
if self._itemSelection is not None:
if self.exec_():
# For the Undo loop...
-
+
# Construct an TimeBase object for setting the Frame Rate (fps)
fps = hiero.core.TimeBase().fromString(self.currentFramerateString())
-
+
# Set the frame rate for the selected BinItmes
for item in self._itemSelection:
- item.setFramerate(fps)
+ item.setFramerate(fps)
return
# This is just a convenience method for returning QActions with a title, triggered method and icon.
def makeAction(title, method, icon = None):
action = QAction(title,None)
action.setIcon(QIcon(icon))
-
+
# We do this magic, so that the title string from the action is used to set the frame rate!
def methodWrapper():
method(title)
-
+
action.triggered.connect( methodWrapper )
return action
@@ -88,13 +88,13 @@ class SetFrameRateMenu:
# ant: Could use hiero.core.defaultFrameRates() here but messes up with string matching because we seem to mix decimal points
- self.frameRates = ['8','12','12.50','15','23.98','24','25','29.97','30','48','50','59.94','60']
+ self.frameRates = ["8","12","12.50","15","23.98","24","25","29.97","30","48","50","59.94","60"]
hiero.core.events.registerInterest("kShowContextMenu/kBin", self.binViewEventHandler)
-
+
self.menuActions = []
def createFrameRateMenus(self,selection):
- selectedClipFPS = [str(bi.activeItem().framerate()) for bi in selection if (isinstance(bi,hiero.core.BinItem) and hasattr(bi,'activeItem'))]
+ selectedClipFPS = [str(bi.activeItem().framerate()) for bi in selection if (isinstance(bi,hiero.core.BinItem) and hasattr(bi,"activeItem"))]
selectedClipFPS = hiero.core.util.uniquify(selectedClipFPS)
sameFrameRate = len(selectedClipFPS)==1
self.menuActions = []
@@ -106,39 +106,41 @@ class SetFrameRateMenu:
self.menuActions+=[makeAction(fps,self.setFrameRateFromMenuSelection, icon="icons:remove active.png")]
else:
self.menuActions+=[makeAction(fps,self.setFrameRateFromMenuSelection, icon=None)]
-
+
# Now add Custom... menu
- self.menuActions+=[makeAction('Custom...',self.setFrameRateFromMenuSelection, icon=None)]
-
+ self.menuActions += [makeAction(
+ "Custom...", self.setFrameRateFromMenuSelection, icon=None)
+ ]
+
frameRateMenu = QMenu("Set Frame Rate")
for a in self.menuActions:
frameRateMenu.addAction(a)
-
+
return frameRateMenu
def setFrameRateFromMenuSelection(self, menuSelectionFPS):
-
- selectedBinItems = [bi.activeItem() for bi in self._selection if (isinstance(bi,hiero.core.BinItem) and hasattr(bi,'activeItem'))]
+
+ selectedBinItems = [bi.activeItem() for bi in self._selection if (isinstance(bi,hiero.core.BinItem) and hasattr(bi,"activeItem"))]
currentProject = selectedBinItems[0].project()
-
+
with currentProject.beginUndo("Set Frame Rate"):
- if menuSelectionFPS == 'Custom...':
+ if menuSelectionFPS == "Custom...":
self._frameRatesDialog = SetFrameRateDialog(itemSelection = selectedBinItems )
self._frameRatesDialog.showDialogAndSetFrameRateFromSelection()
else:
for b in selectedBinItems:
b.setFramerate(hiero.core.TimeBase().fromString(menuSelectionFPS))
-
+
return
# This handles events from the Project Bin View
def binViewEventHandler(self,event):
- if not hasattr(event.sender, 'selection'):
+ if not hasattr(event.sender, "selection"):
# Something has gone wrong, we should only be here if raised
# by the Bin view which gives a selection.
return
-
+
# Reset the selection to None...
self._selection = None
s = event.sender.selection()
@@ -151,9 +153,9 @@ class SetFrameRateMenu:
if len(self._selection)==0:
return
# Creating the menu based on items selected, to highlight which frame rates are contained
-
+
self._frameRateMenu = self.createFrameRateMenus(self._selection)
-
+
# Insert the Set Frame Rate Button before the Set Media Colour Transform Action
for action in event.menu.actions():
if str(action.text()) == "Set Media Colour Transform":
diff --git a/openpype/hosts/hiero/startup/Python/Startup/version_everywhere.py b/openpype/hosts/hiero/api/startup/Python/Startup/version_everywhere.py
similarity index 95%
rename from openpype/hosts/hiero/startup/Python/Startup/version_everywhere.py
rename to openpype/hosts/hiero/api/startup/Python/Startup/version_everywhere.py
index e85e02bfa5..3d60b213d5 100644
--- a/openpype/hosts/hiero/startup/Python/Startup/version_everywhere.py
+++ b/openpype/hosts/hiero/api/startup/Python/Startup/version_everywhere.py
@@ -16,29 +16,29 @@ except:
from PySide2.QtCore import *
-def whereAmI(self, searchType='TrackItem'):
+def whereAmI(self, searchType="TrackItem"):
"""returns a list of TrackItem or Sequnece objects in the Project which contain this Clip.
By default this will return a list of TrackItems where the Clip is used in its project.
- You can also return a list of Sequences by specifying the searchType to be 'Sequence'.
+ You can also return a list of Sequences by specifying the searchType to be "Sequence".
Should consider putting this into hiero.core.Clip by default?
Example usage:
- shotsForClip = clip.whereAmI('TrackItem')
- sequencesForClip = clip.whereAmI('Sequence')
+ shotsForClip = clip.whereAmI("TrackItem")
+ sequencesForClip = clip.whereAmI("Sequence")
"""
proj = self.project()
- if ('TrackItem' not in searchType) and ('Sequence' not in searchType):
- print "searchType argument must be 'TrackItem' or 'Sequence'"
+ if ("TrackItem" not in searchType) and ("Sequence" not in searchType):
+ print("searchType argument must be \"TrackItem\" or \"Sequence\"")
return None
# If user specifies a TrackItem, then it will return
searches = hiero.core.findItemsInProject(proj, searchType)
if len(searches) == 0:
- print 'Unable to find %s in any items of type: %s' % (str(self),
- str(searchType))
+ print("Unable to find {} in any items of type: {}".format(
+ str(self), searchType))
return None
# Case 1: Looking for Shots (trackItems)
@@ -110,7 +110,7 @@ class VersionAllMenu(object):
for shot in sequenceShotManifest[seq]:
updateReportString += ' %s\n (New Version: %s)\n' % (
shot.name(), shot.currentVersion().name())
- updateReportString += '\n'
+ updateReportString += "\n"
infoBox = QMessageBox(hiero.ui.mainWindow())
infoBox.setIcon(QMessageBox.Information)
@@ -202,7 +202,7 @@ class VersionAllMenu(object):
if len(bins) > 0:
# Grab the Clips inside of a Bin and append them to a list
for bin in bins:
- clips = hiero.core.findItemsInBin(bin, 'Clip')
+ clips = hiero.core.findItemsInBin(bin, "Clip")
for clip in clips:
if clip not in clipItems:
clipItems.append(clip)
@@ -291,7 +291,7 @@ class VersionAllMenu(object):
for clip in clipSelection:
# Look to see if it exists in a TrackItem somewhere...
- shotUsage = clip.whereAmI('TrackItem')
+ shotUsage = clip.whereAmI("TrackItem")
# Next, depending on the versionOption, make the appropriate update
# There's probably a more neat/compact way of doing this...
@@ -326,7 +326,7 @@ class VersionAllMenu(object):
# This handles events from the Project Bin View
def binViewEventHandler(self, event):
- if not hasattr(event.sender, 'selection'):
+ if not hasattr(event.sender, "selection"):
# Something has gone wrong, we should only be here if raised
# by the Bin view which gives a selection.
return
diff --git a/openpype/hosts/hiero/startup/Python/StartupUI/PimpMySpreadsheet.py b/openpype/hosts/hiero/api/startup/Python/StartupUI/PimpMySpreadsheet.py
similarity index 74%
rename from openpype/hosts/hiero/startup/Python/StartupUI/PimpMySpreadsheet.py
rename to openpype/hosts/hiero/api/startup/Python/StartupUI/PimpMySpreadsheet.py
index 3d40aa0293..39a65045a7 100644
--- a/openpype/hosts/hiero/startup/Python/StartupUI/PimpMySpreadsheet.py
+++ b/openpype/hosts/hiero/api/startup/Python/StartupUI/PimpMySpreadsheet.py
@@ -15,55 +15,55 @@ except:
from PySide2.QtWidgets import *
from PySide2.QtCore import *
-# Set to True, if you wat 'Set Status' right-click menu, False if not
+# Set to True, if you wat "Set Status" right-click menu, False if not
kAddStatusMenu = True
-# Set to True, if you wat 'Assign Artist' right-click menu, False if not
+# Set to True, if you wat "Assign Artist" right-click menu, False if not
kAssignArtistMenu = True
# Global list of Artist Name Dictionaries
# Note: Override this to add different names, icons, department, IDs.
gArtistList = [{
- 'artistName': 'John Smith',
- 'artistIcon': 'icons:TagActor.png',
- 'artistDepartment': '3D',
- 'artistID': 0
+ "artistName": "John Smith",
+ "artistIcon": "icons:TagActor.png",
+ "artistDepartment": "3D",
+ "artistID": 0
}, {
- 'artistName': 'Savlvador Dali',
- 'artistIcon': 'icons:TagActor.png',
- 'artistDepartment': 'Roto',
- 'artistID': 1
+ "artistName": "Savlvador Dali",
+ "artistIcon": "icons:TagActor.png",
+ "artistDepartment": "Roto",
+ "artistID": 1
}, {
- 'artistName': 'Leonardo Da Vinci',
- 'artistIcon': 'icons:TagActor.png',
- 'artistDepartment': 'Paint',
- 'artistID': 2
+ "artistName": "Leonardo Da Vinci",
+ "artistIcon": "icons:TagActor.png",
+ "artistDepartment": "Paint",
+ "artistID": 2
}, {
- 'artistName': 'Claude Monet',
- 'artistIcon': 'icons:TagActor.png',
- 'artistDepartment': 'Comp',
- 'artistID': 3
+ "artistName": "Claude Monet",
+ "artistIcon": "icons:TagActor.png",
+ "artistDepartment": "Comp",
+ "artistID": 3
}, {
- 'artistName': 'Pablo Picasso',
- 'artistIcon': 'icons:TagActor.png',
- 'artistDepartment': 'Animation',
- 'artistID': 4
+ "artistName": "Pablo Picasso",
+ "artistIcon": "icons:TagActor.png",
+ "artistDepartment": "Animation",
+ "artistID": 4
}]
# Global Dictionary of Status Tags.
# Note: This can be overwritten if you want to add a new status cellType or custom icon
-# Override the gStatusTags dictionary by adding your own 'Status':'Icon.png' key-value pairs.
-# Add new custom keys like so: gStatusTags['For Client'] = 'forClient.png'
+# Override the gStatusTags dictionary by adding your own "Status":"Icon.png" key-value pairs.
+# Add new custom keys like so: gStatusTags["For Client"] = "forClient.png"
gStatusTags = {
- 'Approved': 'icons:status/TagApproved.png',
- 'Unapproved': 'icons:status/TagUnapproved.png',
- 'Ready To Start': 'icons:status/TagReadyToStart.png',
- 'Blocked': 'icons:status/TagBlocked.png',
- 'On Hold': 'icons:status/TagOnHold.png',
- 'In Progress': 'icons:status/TagInProgress.png',
- 'Awaiting Approval': 'icons:status/TagAwaitingApproval.png',
- 'Omitted': 'icons:status/TagOmitted.png',
- 'Final': 'icons:status/TagFinal.png'
+ "Approved": "icons:status/TagApproved.png",
+ "Unapproved": "icons:status/TagUnapproved.png",
+ "Ready To Start": "icons:status/TagReadyToStart.png",
+ "Blocked": "icons:status/TagBlocked.png",
+ "On Hold": "icons:status/TagOnHold.png",
+ "In Progress": "icons:status/TagInProgress.png",
+ "Awaiting Approval": "icons:status/TagAwaitingApproval.png",
+ "Omitted": "icons:status/TagOmitted.png",
+ "Final": "icons:status/TagFinal.png"
}
@@ -78,17 +78,17 @@ class CustomSpreadsheetColumns(QObject):
# Ideally, we'd set this list on a Per Item basis, but this is expensive for a large mixed selection
standardColourSpaces = [
- 'linear', 'sRGB', 'rec709', 'Cineon', 'Gamma1.8', 'Gamma2.2',
- 'Panalog', 'REDLog', 'ViperLog'
+ "linear", "sRGB", "rec709", "Cineon", "Gamma1.8", "Gamma2.2",
+ "Panalog", "REDLog", "ViperLog"
]
arriColourSpaces = [
- 'Video - Rec709', 'LogC - Camera Native', 'Video - P3', 'ACES',
- 'LogC - Film', 'LogC - Wide Gamut'
+ "Video - Rec709", "LogC - Camera Native", "Video - P3", "ACES",
+ "LogC - Film", "LogC - Wide Gamut"
]
r3dColourSpaces = [
- 'Linear', 'Rec709', 'REDspace', 'REDlog', 'PDlog685', 'PDlog985',
- 'CustomPDlog', 'REDgamma', 'SRGB', 'REDlogFilm', 'REDgamma2',
- 'REDgamma3'
+ "Linear", "Rec709", "REDspace", "REDlog", "PDlog685", "PDlog985",
+ "CustomPDlog", "REDgamma", "SRGB", "REDlogFilm", "REDgamma2",
+ "REDgamma3"
]
gColourSpaces = standardColourSpaces + arriColourSpaces + r3dColourSpaces
@@ -97,52 +97,52 @@ class CustomSpreadsheetColumns(QObject):
# This is the list of Columns available
gCustomColumnList = [
{
- 'name': 'Tags',
- 'cellType': 'readonly'
+ "name": "Tags",
+ "cellType": "readonly"
},
{
- 'name': 'Colourspace',
- 'cellType': 'dropdown'
+ "name": "Colourspace",
+ "cellType": "dropdown"
},
{
- 'name': 'Notes',
- 'cellType': 'readonly'
+ "name": "Notes",
+ "cellType": "readonly"
},
{
- 'name': 'FileType',
- 'cellType': 'readonly'
+ "name": "FileType",
+ "cellType": "readonly"
},
{
- 'name': 'Shot Status',
- 'cellType': 'dropdown'
+ "name": "Shot Status",
+ "cellType": "dropdown"
},
{
- 'name': 'Thumbnail',
- 'cellType': 'readonly'
+ "name": "Thumbnail",
+ "cellType": "readonly"
},
{
- 'name': 'MediaType',
- 'cellType': 'readonly'
+ "name": "MediaType",
+ "cellType": "readonly"
},
{
- 'name': 'Width',
- 'cellType': 'readonly'
+ "name": "Width",
+ "cellType": "readonly"
},
{
- 'name': 'Height',
- 'cellType': 'readonly'
+ "name": "Height",
+ "cellType": "readonly"
},
{
- 'name': 'Pixel Aspect',
- 'cellType': 'readonly'
+ "name": "Pixel Aspect",
+ "cellType": "readonly"
},
{
- 'name': 'Artist',
- 'cellType': 'dropdown'
+ "name": "Artist",
+ "cellType": "dropdown"
},
{
- 'name': 'Department',
- 'cellType': 'readonly'
+ "name": "Department",
+ "cellType": "readonly"
},
]
@@ -156,7 +156,7 @@ class CustomSpreadsheetColumns(QObject):
"""
Return the name of a custom column
"""
- return self.gCustomColumnList[column]['name']
+ return self.gCustomColumnList[column]["name"]
def getTagsString(self, item):
"""
@@ -173,7 +173,7 @@ class CustomSpreadsheetColumns(QObject):
"""
Convenience method for returning all the Notes in a Tag as a string
"""
- notes = ''
+ notes = ""
tags = item.tags()
for tag in tags:
note = tag.note()
@@ -186,67 +186,67 @@ class CustomSpreadsheetColumns(QObject):
Return the data in a cell
"""
currentColumn = self.gCustomColumnList[column]
- if currentColumn['name'] == 'Tags':
+ if currentColumn["name"] == "Tags":
return self.getTagsString(item)
- if currentColumn['name'] == 'Colourspace':
+ if currentColumn["name"] == "Colourspace":
try:
colTransform = item.sourceMediaColourTransform()
except:
- colTransform = '--'
+ colTransform = "--"
return colTransform
- if currentColumn['name'] == 'Notes':
+ if currentColumn["name"] == "Notes":
try:
note = self.getNotes(item)
except:
- note = ''
+ note = ""
return note
- if currentColumn['name'] == 'FileType':
- fileType = '--'
+ if currentColumn["name"] == "FileType":
+ fileType = "--"
M = item.source().mediaSource().metadata()
- if M.hasKey('foundry.source.type'):
- fileType = M.value('foundry.source.type')
- elif M.hasKey('media.input.filereader'):
- fileType = M.value('media.input.filereader')
+ if M.hasKey("foundry.source.type"):
+ fileType = M.value("foundry.source.type")
+ elif M.hasKey("media.input.filereader"):
+ fileType = M.value("media.input.filereader")
return fileType
- if currentColumn['name'] == 'Shot Status':
+ if currentColumn["name"] == "Shot Status":
status = item.status()
if not status:
status = "--"
return str(status)
- if currentColumn['name'] == 'MediaType':
+ if currentColumn["name"] == "MediaType":
M = item.mediaType()
- return str(M).split('MediaType')[-1].replace('.k', '')
+ return str(M).split("MediaType")[-1].replace(".k", "")
- if currentColumn['name'] == 'Thumbnail':
+ if currentColumn["name"] == "Thumbnail":
return str(item.eventNumber())
- if currentColumn['name'] == 'Width':
+ if currentColumn["name"] == "Width":
return str(item.source().format().width())
- if currentColumn['name'] == 'Height':
+ if currentColumn["name"] == "Height":
return str(item.source().format().height())
- if currentColumn['name'] == 'Pixel Aspect':
+ if currentColumn["name"] == "Pixel Aspect":
return str(item.source().format().pixelAspect())
- if currentColumn['name'] == 'Artist':
+ if currentColumn["name"] == "Artist":
if item.artist():
- name = item.artist()['artistName']
+ name = item.artist()["artistName"]
return name
else:
- return '--'
+ return "--"
- if currentColumn['name'] == 'Department':
+ if currentColumn["name"] == "Department":
if item.artist():
- dep = item.artist()['artistDepartment']
+ dep = item.artist()["artistDepartment"]
return dep
else:
- return '--'
+ return "--"
return ""
@@ -262,10 +262,10 @@ class CustomSpreadsheetColumns(QObject):
Return the tooltip for a cell
"""
currentColumn = self.gCustomColumnList[column]
- if currentColumn['name'] == 'Tags':
+ if currentColumn["name"] == "Tags":
return str([item.name() for item in item.tags()])
- if currentColumn['name'] == 'Notes':
+ if currentColumn["name"] == "Notes":
return str(self.getNotes(item))
return ""
@@ -296,24 +296,24 @@ class CustomSpreadsheetColumns(QObject):
Return the icon for a cell
"""
currentColumn = self.gCustomColumnList[column]
- if currentColumn['name'] == 'Colourspace':
+ if currentColumn["name"] == "Colourspace":
return QIcon("icons:LUT.png")
- if currentColumn['name'] == 'Shot Status':
+ if currentColumn["name"] == "Shot Status":
status = item.status()
if status:
return QIcon(gStatusTags[status])
- if currentColumn['name'] == 'MediaType':
+ if currentColumn["name"] == "MediaType":
mediaType = item.mediaType()
if mediaType == hiero.core.TrackItem.kVideo:
return QIcon("icons:VideoOnly.png")
elif mediaType == hiero.core.TrackItem.kAudio:
return QIcon("icons:AudioOnly.png")
- if currentColumn['name'] == 'Artist':
+ if currentColumn["name"] == "Artist":
try:
- return QIcon(item.artist()['artistIcon'])
+ return QIcon(item.artist()["artistIcon"])
except:
return None
return None
@@ -322,9 +322,9 @@ class CustomSpreadsheetColumns(QObject):
"""
Return the size hint for a cell
"""
- currentColumnName = self.gCustomColumnList[column]['name']
+ currentColumnName = self.gCustomColumnList[column]["name"]
- if currentColumnName == 'Thumbnail':
+ if currentColumnName == "Thumbnail":
return QSize(90, 50)
return QSize(50, 50)
@@ -335,7 +335,7 @@ class CustomSpreadsheetColumns(QObject):
with the default cell painting.
"""
currentColumn = self.gCustomColumnList[column]
- if currentColumn['name'] == 'Tags':
+ if currentColumn["name"] == "Tags":
if option.state & QStyle.State_Selected:
painter.fillRect(option.rect, option.palette.highlight())
iconSize = 20
@@ -348,14 +348,14 @@ class CustomSpreadsheetColumns(QObject):
painter.setClipRect(option.rect)
for tag in item.tags():
M = tag.metadata()
- if not (M.hasKey('tag.status')
- or M.hasKey('tag.artistID')):
+ if not (M.hasKey("tag.status")
+ or M.hasKey("tag.artistID")):
QIcon(tag.icon()).paint(painter, r, Qt.AlignLeft)
r.translate(r.width() + 2, 0)
painter.restore()
return True
- if currentColumn['name'] == 'Thumbnail':
+ if currentColumn["name"] == "Thumbnail":
imageView = None
pen = QPen()
r = QRect(option.rect.x() + 2, (option.rect.y() +
@@ -409,35 +409,35 @@ class CustomSpreadsheetColumns(QObject):
self.currentView = view
currentColumn = self.gCustomColumnList[column]
- if currentColumn['cellType'] == 'readonly':
+ if currentColumn["cellType"] == "readonly":
cle = QLabel()
cle.setEnabled(False)
cle.setVisible(False)
return cle
- if currentColumn['name'] == 'Colourspace':
+ if currentColumn["name"] == "Colourspace":
cb = QComboBox()
for colourspace in self.gColourSpaces:
cb.addItem(colourspace)
cb.currentIndexChanged.connect(self.colourspaceChanged)
return cb
- if currentColumn['name'] == 'Shot Status':
+ if currentColumn["name"] == "Shot Status":
cb = QComboBox()
- cb.addItem('')
+ cb.addItem("")
for key in gStatusTags.keys():
cb.addItem(QIcon(gStatusTags[key]), key)
- cb.addItem('--')
+ cb.addItem("--")
cb.currentIndexChanged.connect(self.statusChanged)
return cb
- if currentColumn['name'] == 'Artist':
+ if currentColumn["name"] == "Artist":
cb = QComboBox()
- cb.addItem('')
+ cb.addItem("")
for artist in gArtistList:
- cb.addItem(artist['artistName'])
- cb.addItem('--')
+ cb.addItem(artist["artistName"])
+ cb.addItem("--")
cb.currentIndexChanged.connect(self.artistNameChanged)
return cb
return None
@@ -479,15 +479,15 @@ class CustomSpreadsheetColumns(QObject):
status = self.sender().currentText()
project = selection[0].project()
with project.beginUndo("Set Status"):
- # A string of '--' characters denotes clear the status
- if status != '--':
+ # A string of "--" characters denotes clear the status
+ if status != "--":
for trackItem in selection:
trackItem.setStatus(status)
else:
for trackItem in selection:
tTags = trackItem.tags()
for tag in tTags:
- if tag.metadata().hasKey('tag.status'):
+ if tag.metadata().hasKey("tag.status"):
trackItem.removeTag(tag)
break
@@ -500,15 +500,15 @@ class CustomSpreadsheetColumns(QObject):
name = self.sender().currentText()
project = selection[0].project()
with project.beginUndo("Assign Artist"):
- # A string of '--' denotes clear the assignee...
- if name != '--':
+ # A string of "--" denotes clear the assignee...
+ if name != "--":
for trackItem in selection:
trackItem.setArtistByName(name)
else:
for trackItem in selection:
tTags = trackItem.tags()
for tag in tTags:
- if tag.metadata().hasKey('tag.artistID'):
+ if tag.metadata().hasKey("tag.artistID"):
trackItem.removeTag(tag)
break
@@ -518,7 +518,7 @@ def _getArtistFromID(self, artistID):
global gArtistList
artist = [
element for element in gArtistList
- if element['artistID'] == int(artistID)
+ if element["artistID"] == int(artistID)
]
if not artist:
return None
@@ -530,7 +530,7 @@ def _getArtistFromName(self, artistName):
global gArtistList
artist = [
element for element in gArtistList
- if element['artistName'] == artistName
+ if element["artistName"] == artistName
]
if not artist:
return None
@@ -542,8 +542,8 @@ def _artist(self):
artist = None
tags = self.tags()
for tag in tags:
- if tag.metadata().hasKey('tag.artistID'):
- artistID = tag.metadata().value('tag.artistID')
+ if tag.metadata().hasKey("tag.artistID"):
+ artistID = tag.metadata().value("tag.artistID")
artist = self.getArtistFromID(artistID)
return artist
@@ -554,30 +554,30 @@ def _updateArtistTag(self, artistDict):
artistTag = None
tags = self.tags()
for tag in tags:
- if tag.metadata().hasKey('tag.artistID'):
+ if tag.metadata().hasKey("tag.artistID"):
artistTag = tag
break
if not artistTag:
- artistTag = hiero.core.Tag('Artist')
- artistTag.setIcon(artistDict['artistIcon'])
- artistTag.metadata().setValue('tag.artistID',
- str(artistDict['artistID']))
- artistTag.metadata().setValue('tag.artistName',
- str(artistDict['artistName']))
- artistTag.metadata().setValue('tag.artistDepartment',
- str(artistDict['artistDepartment']))
+ artistTag = hiero.core.Tag("Artist")
+ artistTag.setIcon(artistDict["artistIcon"])
+ artistTag.metadata().setValue("tag.artistID",
+ str(artistDict["artistID"]))
+ artistTag.metadata().setValue("tag.artistName",
+ str(artistDict["artistName"]))
+ artistTag.metadata().setValue("tag.artistDepartment",
+ str(artistDict["artistDepartment"]))
self.sequence().editFinished()
self.addTag(artistTag)
self.sequence().editFinished()
return
- artistTag.setIcon(artistDict['artistIcon'])
- artistTag.metadata().setValue('tag.artistID', str(artistDict['artistID']))
- artistTag.metadata().setValue('tag.artistName',
- str(artistDict['artistName']))
- artistTag.metadata().setValue('tag.artistDepartment',
- str(artistDict['artistDepartment']))
+ artistTag.setIcon(artistDict["artistIcon"])
+ artistTag.metadata().setValue("tag.artistID", str(artistDict["artistID"]))
+ artistTag.metadata().setValue("tag.artistName",
+ str(artistDict["artistName"]))
+ artistTag.metadata().setValue("tag.artistDepartment",
+ str(artistDict["artistDepartment"]))
self.sequence().editFinished()
return
@@ -588,8 +588,9 @@ def _setArtistByName(self, artistName):
artist = self.getArtistFromName(artistName)
if not artist:
- print 'Artist name: %s was not found in the gArtistList.' % str(
- artistName)
+ print((
+ "Artist name: {} was not found in "
+ "the gArtistList.").format(artistName))
return
# Do the update.
@@ -602,8 +603,8 @@ def _setArtistByID(self, artistID):
artist = self.getArtistFromID(artistID)
if not artist:
- print 'Artist name: %s was not found in the gArtistList.' % str(
- artistID)
+ print("Artist name: {} was not found in the gArtistList.".format(
+ artistID))
return
# Do the update.
@@ -625,15 +626,15 @@ def _status(self):
status = None
tags = self.tags()
for tag in tags:
- if tag.metadata().hasKey('tag.status'):
- status = tag.metadata().value('tag.status')
+ if tag.metadata().hasKey("tag.status"):
+ status = tag.metadata().value("tag.status")
return status
def _setStatus(self, status):
"""setShotStatus(status) -> Method to set the Status of a Shot.
Adds a special kind of status Tag to a TrackItem
- Example: myTrackItem.setStatus('Final')
+ Example: myTrackItem.setStatus("Final")
@param status - a string, corresponding to the Status name
"""
@@ -641,25 +642,25 @@ def _setStatus(self, status):
# Get a valid Tag object from the Global list of statuses
if not status in gStatusTags.keys():
- print 'Status requested was not a valid Status string.'
+ print("Status requested was not a valid Status string.")
return
# A shot should only have one status. Check if one exists and set accordingly
statusTag = None
tags = self.tags()
for tag in tags:
- if tag.metadata().hasKey('tag.status'):
+ if tag.metadata().hasKey("tag.status"):
statusTag = tag
break
if not statusTag:
- statusTag = hiero.core.Tag('Status')
+ statusTag = hiero.core.Tag("Status")
statusTag.setIcon(gStatusTags[status])
- statusTag.metadata().setValue('tag.status', status)
+ statusTag.metadata().setValue("tag.status", status)
self.addTag(statusTag)
statusTag.setIcon(gStatusTags[status])
- statusTag.metadata().setValue('tag.status', status)
+ statusTag.metadata().setValue("tag.status", status)
self.sequence().editFinished()
return
@@ -743,7 +744,7 @@ class SetStatusMenu(QMenu):
# This handles events from the Project Bin View
def eventHandler(self, event):
- if not hasattr(event.sender, 'selection'):
+ if not hasattr(event.sender, "selection"):
# Something has gone wrong, we should only be here if raised
# by the Timeline/Spreadsheet view which gives a selection.
return
@@ -781,9 +782,9 @@ class AssignArtistMenu(QMenu):
for artist in self.artists:
self.menuActions += [
titleStringTriggeredAction(
- artist['artistName'],
+ artist["artistName"],
self.setArtistFromMenuSelection,
- icon=artist['artistIcon'])
+ icon=artist["artistIcon"])
]
def setArtistFromMenuSelection(self, menuSelectionArtist):
@@ -818,7 +819,7 @@ class AssignArtistMenu(QMenu):
# This handles events from the Project Bin View
def eventHandler(self, event):
- if not hasattr(event.sender, 'selection'):
+ if not hasattr(event.sender, "selection"):
# Something has gone wrong, we should only be here if raised
# by the Timeline/Spreadsheet view which gives a selection.
return
@@ -833,7 +834,7 @@ class AssignArtistMenu(QMenu):
event.menu.addMenu(self)
-# Add the 'Set Status' context menu to Timeline and Spreadsheet
+# Add the "Set Status" context menu to Timeline and Spreadsheet
if kAddStatusMenu:
setStatusMenu = SetStatusMenu()
diff --git a/openpype/hosts/hiero/startup/Python/StartupUI/Purge.py b/openpype/hosts/hiero/api/startup/Python/StartupUI/Purge.py
similarity index 89%
rename from openpype/hosts/hiero/startup/Python/StartupUI/Purge.py
rename to openpype/hosts/hiero/api/startup/Python/StartupUI/Purge.py
index 4d2ab255ad..7b3cb11be3 100644
--- a/openpype/hosts/hiero/startup/Python/StartupUI/Purge.py
+++ b/openpype/hosts/hiero/api/startup/Python/StartupUI/Purge.py
@@ -23,7 +23,7 @@ class PurgeUnusedAction(QAction):
self.triggered.connect(self.PurgeUnused)
hiero.core.events.registerInterest("kShowContextMenu/kBin",
self.eventHandler)
- self.setIcon(QIcon('icons:TagDelete.png'))
+ self.setIcon(QIcon("icons:TagDelete.png"))
# Method to return whether a Bin is empty...
def binIsEmpty(self, b):
@@ -67,19 +67,19 @@ class PurgeUnusedAction(QAction):
msgBox.setDefaultButton(QMessageBox.Ok)
ret = msgBox.exec_()
if ret == QMessageBox.Cancel:
- print 'Not purging anything.'
+ print("Not purging anything.")
elif ret == QMessageBox.Ok:
- with proj.beginUndo('Purge Unused Clips'):
+ with proj.beginUndo("Purge Unused Clips"):
BINS = []
for clip in CLIPSTOREMOVE:
BI = clip.binItem()
B = BI.parentBin()
BINS += [B]
- print 'Removing:', BI
+ print("Removing: {}".format(BI))
try:
B.removeItem(BI)
except:
- print 'Unable to remove: ' + BI
+ print("Unable to remove: {}".format(BI))
return
# For each sequence, iterate through each track Item, see if the Clip is in the CLIPS list.
@@ -104,24 +104,24 @@ class PurgeUnusedAction(QAction):
ret = msgBox.exec_()
if ret == QMessageBox.Cancel:
- print 'Cancel'
+ print("Cancel")
return
elif ret == QMessageBox.Ok:
BINS = []
- with proj.beginUndo('Purge Unused Clips'):
+ with proj.beginUndo("Purge Unused Clips"):
# Delete the rest of the Clips
for clip in CLIPSTOREMOVE:
BI = clip.binItem()
B = BI.parentBin()
BINS += [B]
- print 'Removing:', BI
+ print("Removing: {}".format(BI))
try:
B.removeItem(BI)
except:
- print 'Unable to remove: ' + BI
+ print("Unable to remove: {}".format(BI))
def eventHandler(self, event):
- if not hasattr(event.sender, 'selection'):
+ if not hasattr(event.sender, "selection"):
# Something has gone wrong, we shouldn't only be here if raised
# by the Bin view which will give a selection.
return
diff --git a/openpype/hosts/hiero/startup/Python/StartupUI/nukeStyleKeyboardShortcuts.py b/openpype/hosts/hiero/api/startup/Python/StartupUI/nukeStyleKeyboardShortcuts.py
similarity index 72%
rename from openpype/hosts/hiero/startup/Python/StartupUI/nukeStyleKeyboardShortcuts.py
rename to openpype/hosts/hiero/api/startup/Python/StartupUI/nukeStyleKeyboardShortcuts.py
index 41c192ab15..4172b2ff85 100644
--- a/openpype/hosts/hiero/startup/Python/StartupUI/nukeStyleKeyboardShortcuts.py
+++ b/openpype/hosts/hiero/api/startup/Python/StartupUI/nukeStyleKeyboardShortcuts.py
@@ -13,21 +13,22 @@ except:
#----------------------------------------------
a = hiero.ui.findMenuAction('Import File(s)...')
-# Note: You probably best to make this 'Ctrl+R' - currently conflicts with 'Red' in the Viewer!
-a.setShortcut(QKeySequence('R'))
+# Note: You probably best to make this 'Ctrl+R' - currently conflicts with "Red" in the Viewer!
+a.setShortcut(QKeySequence("R"))
#----------------------------------------------
a = hiero.ui.findMenuAction('Import Folder(s)...')
a.setShortcut(QKeySequence('Shift+R'))
#----------------------------------------------
-a = hiero.ui.findMenuAction('Import EDL/XML/AAF...')
+a = hiero.ui.findMenuAction("Import EDL/XML/AAF...")
a.setShortcut(QKeySequence('Ctrl+Shift+O'))
#----------------------------------------------
-a = hiero.ui.findMenuAction('Metadata View')
-a.setShortcut(QKeySequence('I'))
+a = hiero.ui.findMenuAction("Metadata View")
+a.setShortcut(QKeySequence("I"))
#----------------------------------------------
-a = hiero.ui.findMenuAction('Edit Settings')
-a.setShortcut(QKeySequence('S'))
+a = hiero.ui.findMenuAction("Edit Settings")
+a.setShortcut(QKeySequence("S"))
#----------------------------------------------
-a = hiero.ui.findMenuAction('Monitor Output')
-a.setShortcut(QKeySequence('Ctrl+U'))
+a = hiero.ui.findMenuAction("Monitor Output")
+if a:
+ a.setShortcut(QKeySequence('Ctrl+U'))
#----------------------------------------------
diff --git a/openpype/hosts/hiero/startup/Python/StartupUI/otioimporter/OTIOImport.py b/openpype/hosts/hiero/api/startup/Python/StartupUI/otioimporter/OTIOImport.py
similarity index 94%
rename from openpype/hosts/hiero/startup/Python/StartupUI/otioimporter/OTIOImport.py
rename to openpype/hosts/hiero/api/startup/Python/StartupUI/otioimporter/OTIOImport.py
index 7efb352ed2..17c044f3ec 100644
--- a/openpype/hosts/hiero/startup/Python/StartupUI/otioimporter/OTIOImport.py
+++ b/openpype/hosts/hiero/api/startup/Python/StartupUI/otioimporter/OTIOImport.py
@@ -44,16 +44,16 @@ def get_transition_type(otio_item, otio_track):
_out = None
if _in and _out:
- return 'dissolve'
+ return "dissolve"
elif _in and not _out:
- return 'fade_out'
+ return "fade_out"
elif not _in and _out:
- return 'fade_in'
+ return "fade_in"
else:
- return 'unknown'
+ return "unknown"
def find_trackitem(name, hiero_track):
@@ -84,10 +84,10 @@ def apply_transition(otio_track, otio_item, track):
# Figure out track kind for getattr below
if isinstance(track, hiero.core.VideoTrack):
- kind = ''
+ kind = ""
else:
- kind = 'Audio'
+ kind = "Audio"
try:
# Gather TrackItems involved in trasition
@@ -98,7 +98,7 @@ def apply_transition(otio_track, otio_item, track):
)
# Create transition object
- if transition_type == 'dissolve':
+ if transition_type == "dissolve":
transition_func = getattr(
hiero.core.Transition,
'create{kind}DissolveTransition'.format(kind=kind)
@@ -111,7 +111,7 @@ def apply_transition(otio_track, otio_item, track):
otio_item.out_offset.value
)
- elif transition_type == 'fade_in':
+ elif transition_type == "fade_in":
transition_func = getattr(
hiero.core.Transition,
'create{kind}FadeInTransition'.format(kind=kind)
@@ -121,7 +121,7 @@ def apply_transition(otio_track, otio_item, track):
otio_item.out_offset.value
)
- elif transition_type == 'fade_out':
+ elif transition_type == "fade_out":
transition_func = getattr(
hiero.core.Transition,
'create{kind}FadeOutTransition'.format(kind=kind)
@@ -150,11 +150,11 @@ def apply_transition(otio_track, otio_item, track):
def prep_url(url_in):
url = unquote(url_in)
- if url.startswith('file://localhost/'):
- return url.replace('file://localhost/', '')
+ if url.startswith("file://localhost/"):
+ return url.replace("file://localhost/", "")
url = '{url}'.format(
- sep=url.startswith(os.sep) and '' or os.sep,
+ sep=url.startswith(os.sep) and "" or os.sep,
url=url.startswith(os.sep) and url[1:] or url
)
@@ -228,8 +228,8 @@ def add_metadata(metadata, hiero_item):
continue
if value is not None:
- if not key.startswith('tag.'):
- key = 'tag.' + key
+ if not key.startswith("tag."):
+ key = "tag." + key
hiero_item.metadata().setValue(key, str(value))
@@ -371,10 +371,10 @@ def build_sequence(
if not sequence:
# Create a Sequence
- sequence = hiero.core.Sequence(otio_timeline.name or 'OTIOSequence')
+ sequence = hiero.core.Sequence(otio_timeline.name or "OTIOSequence")
# Set sequence settings from otio timeline if available
- if hasattr(otio_timeline, 'global_start_time'):
+ if hasattr(otio_timeline, "global_start_time"):
if otio_timeline.global_start_time:
start_time = otio_timeline.global_start_time
sequence.setFramerate(start_time.rate)
@@ -414,7 +414,7 @@ def build_sequence(
if isinstance(otio_clip, otio.schema.Stack):
bar = hiero.ui.mainWindow().statusBar()
bar.showMessage(
- 'Nested sequences are created separately.',
+ "Nested sequences are created separately.",
timeout=3000
)
build_sequence(otio_clip, project, otio_track.kind)
diff --git a/openpype/hosts/hiero/startup/Python/StartupUI/otioimporter/__init__.py b/openpype/hosts/hiero/api/startup/Python/StartupUI/otioimporter/__init__.py
similarity index 84%
rename from openpype/hosts/hiero/startup/Python/StartupUI/otioimporter/__init__.py
rename to openpype/hosts/hiero/api/startup/Python/StartupUI/otioimporter/__init__.py
index 0f0a643909..91be4d02aa 100644
--- a/openpype/hosts/hiero/startup/Python/StartupUI/otioimporter/__init__.py
+++ b/openpype/hosts/hiero/api/startup/Python/StartupUI/otioimporter/__init__.py
@@ -9,19 +9,19 @@ import hiero.core
import PySide2.QtWidgets as qw
-from openpype.hosts.hiero.otio.hiero_import import load_otio
+from openpype.hosts.hiero.api.otio.hiero_import import load_otio
class OTIOProjectSelect(qw.QDialog):
def __init__(self, projects, *args, **kwargs):
super(OTIOProjectSelect, self).__init__(*args, **kwargs)
- self.setWindowTitle('Please select active project')
+ self.setWindowTitle("Please select active project")
self.layout = qw.QVBoxLayout()
self.label = qw.QLabel(
- 'Unable to determine which project to import sequence to.\n'
- 'Please select one.'
+ "Unable to determine which project to import sequence to.\n"
+ "Please select one."
)
self.layout.addWidget(self.label)
@@ -45,7 +45,7 @@ def get_sequence(view):
elif isinstance(view, hiero.ui.BinView):
for item in view.selection():
- if not hasattr(item, 'acitveItem'):
+ if not hasattr(item, "acitveItem"):
continue
if isinstance(item.activeItem(), hiero.core.Sequence):
@@ -57,13 +57,13 @@ def get_sequence(view):
def OTIO_menu_action(event):
# Menu actions
otio_import_action = hiero.ui.createMenuAction(
- 'Import OTIO...',
+ "Import OTIO...",
open_otio_file,
icon=None
)
otio_add_track_action = hiero.ui.createMenuAction(
- 'New Track(s) from OTIO...',
+ "New Track(s) from OTIO...",
open_otio_file,
icon=None
)
@@ -80,19 +80,19 @@ def OTIO_menu_action(event):
otio_add_track_action.setEnabled(True)
for action in event.menu.actions():
- if action.text() == 'Import':
+ if action.text() == "Import":
action.menu().addAction(otio_import_action)
action.menu().addAction(otio_add_track_action)
- elif action.text() == 'New Track':
+ elif action.text() == "New Track":
action.menu().addAction(otio_add_track_action)
def open_otio_file():
files = hiero.ui.openFileBrowser(
- caption='Please select an OTIO file of choice',
- pattern='*.otio',
- requiredExtension='.otio'
+ caption="Please select an OTIO file of choice",
+ pattern="*.otio",
+ requiredExtension=".otio"
)
selection = None
@@ -117,7 +117,7 @@ def open_otio_file():
else:
bar = hiero.ui.mainWindow().statusBar()
bar.showMessage(
- 'OTIO Import aborted by user',
+ "OTIO Import aborted by user",
timeout=3000
)
return
diff --git a/openpype/hosts/hiero/startup/Python/StartupUI/setPosterFrame.py b/openpype/hosts/hiero/api/startup/Python/StartupUI/setPosterFrame.py
similarity index 94%
rename from openpype/hosts/hiero/startup/Python/StartupUI/setPosterFrame.py
rename to openpype/hosts/hiero/api/startup/Python/StartupUI/setPosterFrame.py
index 18398aa119..8614d51bb0 100644
--- a/openpype/hosts/hiero/startup/Python/StartupUI/setPosterFrame.py
+++ b/openpype/hosts/hiero/api/startup/Python/StartupUI/setPosterFrame.py
@@ -10,15 +10,15 @@ except:
def setPosterFrame(posterFrame=.5):
- '''
+ """
Update the poster frame of the given clipItmes
posterFrame = .5 uses the centre frame, a value of 0 uses the first frame, a value of 1 uses the last frame
- '''
+ """
view = hiero.ui.activeView()
selectedBinItems = view.selection()
selectedClipItems = [(item.activeItem()
- if hasattr(item, 'activeItem') else item)
+ if hasattr(item, "activeItem") else item)
for item in selectedBinItems]
for clip in selectedClipItems:
diff --git a/openpype/hosts/hiero/startup/TaskPresets/10.5/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml b/openpype/hosts/hiero/api/startup/TaskPresets/10.5/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml
similarity index 100%
rename from openpype/hosts/hiero/startup/TaskPresets/10.5/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml
rename to openpype/hosts/hiero/api/startup/TaskPresets/10.5/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml
diff --git a/openpype/hosts/hiero/startup/TaskPresets/11.1/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml b/openpype/hosts/hiero/api/startup/TaskPresets/11.1/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml
similarity index 100%
rename from openpype/hosts/hiero/startup/TaskPresets/11.1/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml
rename to openpype/hosts/hiero/api/startup/TaskPresets/11.1/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml
diff --git a/openpype/hosts/hiero/startup/TaskPresets/11.2/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml b/openpype/hosts/hiero/api/startup/TaskPresets/11.2/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml
similarity index 100%
rename from openpype/hosts/hiero/startup/TaskPresets/11.2/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml
rename to openpype/hosts/hiero/api/startup/TaskPresets/11.2/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml
diff --git a/openpype/hosts/hiero/api/workio.py b/openpype/hosts/hiero/api/workio.py
index 15ffbf84d8..dacb11624f 100644
--- a/openpype/hosts/hiero/api/workio.py
+++ b/openpype/hosts/hiero/api/workio.py
@@ -65,13 +65,9 @@ def open_file(filepath):
def current_file():
current_file = hiero.core.projects()[-1].path()
- normalised = os.path.normpath(current_file)
-
- # Unsaved current file
- if normalised == "":
+ if not current_file:
return None
-
- return normalised
+ return os.path.normpath(current_file)
def work_root(session):
diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py
index 85b4e273d5..bf3a779ab1 100644
--- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py
+++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py
@@ -1,11 +1,9 @@
import pyblish
import openpype
from openpype.hosts.hiero import api as phiero
-from openpype.hosts.hiero.otio import hiero_export
+from openpype.hosts.hiero.api.otio import hiero_export
import hiero
-from compiler.ast import flatten
-
# # developer reload modules
from pprint import pformat
@@ -339,10 +337,10 @@ class PrecollectInstances(pyblish.api.ContextPlugin):
continue
track_index = track.trackIndex()
- _sub_track_items = flatten(track.subTrackItems())
+ _sub_track_items = phiero.flatten(track.subTrackItems())
# continue only if any subtrack items are collected
- if len(_sub_track_items) < 1:
+ if not list(_sub_track_items):
continue
enabled_sti = []
@@ -357,7 +355,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin):
enabled_sti.append(_sti)
# continue only if any subtrack items are collected
- if len(enabled_sti) < 1:
+ if not enabled_sti:
continue
# add collection of subtrackitems to dict
@@ -371,7 +369,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin):
Returns list of Clip's hiero.core.Annotation
"""
annotations = []
- subTrackItems = flatten(clip.subTrackItems())
+ subTrackItems = phiero.flatten(clip.subTrackItems())
annotations += [item for item in subTrackItems if isinstance(
item, hiero.core.Annotation)]
return annotations
@@ -382,7 +380,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin):
Returns list of Clip's hiero.core.SubTrackItem
"""
subtracks = []
- subTrackItems = flatten(clip.parent().subTrackItems())
+ subTrackItems = phiero.flatten(clip.parent().subTrackItems())
for item in subTrackItems:
if "TimeWarp" in item.name():
continue
diff --git a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py
index 7db155048f..d48d6949bd 100644
--- a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py
+++ b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py
@@ -4,7 +4,7 @@ import hiero.ui
from openpype.hosts.hiero import api as phiero
from avalon import api as avalon
from pprint import pformat
-from openpype.hosts.hiero.otio import hiero_export
+from openpype.hosts.hiero.api.otio import hiero_export
from Qt.QtGui import QPixmap
import tempfile
diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_retime.py b/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_retime.py
index de05414c88..f0e0f1a1a3 100644
--- a/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_retime.py
+++ b/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_retime.py
@@ -1,7 +1,7 @@
from pyblish import api
import hiero
import math
-from openpype.hosts.hiero.otio.hiero_export import create_otio_time_range
+from openpype.hosts.hiero.api.otio.hiero_export import create_otio_time_range
class PrecollectRetime(api.InstancePlugin):
"""Calculate Retiming of selected track items."""
diff --git a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/__init__.py b/openpype/hosts/hiero/startup/Python/Startup/otioexporter/__init__.py
deleted file mode 100644
index 3c09655f01..0000000000
--- a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from OTIOExportTask import OTIOExportTask
-from OTIOExportUI import OTIOExportUI
-
-__all__ = [
- 'OTIOExportTask',
- 'OTIOExportUI'
-]
diff --git a/openpype/hosts/hiero/startup/Python/Startup/selection_tracker.py b/openpype/hosts/hiero/startup/Python/Startup/selection_tracker.py
deleted file mode 100644
index b7e05fed7c..0000000000
--- a/openpype/hosts/hiero/startup/Python/Startup/selection_tracker.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""Puts the selection project into 'hiero.selection'"""
-
-import hiero
-
-
-def selectionChanged(event):
- hiero.selection = event.sender.selection()
-
-hiero.core.events.registerInterest('kSelectionChanged', selectionChanged)
diff --git a/openpype/hosts/maya/plugins/load/load_image_plane.py b/openpype/hosts/maya/plugins/load/load_image_plane.py
index f2640dc2eb..eea5844e8b 100644
--- a/openpype/hosts/maya/plugins/load/load_image_plane.py
+++ b/openpype/hosts/maya/plugins/load/load_image_plane.py
@@ -13,10 +13,14 @@ class CameraWindow(QtWidgets.QDialog):
self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint)
self.camera = None
+ self.static_image_plane = False
+ self.show_in_all_views = False
self.widgets = {
"label": QtWidgets.QLabel("Select camera for image plane."),
"list": QtWidgets.QListWidget(),
+ "staticImagePlane": QtWidgets.QCheckBox(),
+ "showInAllViews": QtWidgets.QCheckBox(),
"warning": QtWidgets.QLabel("No cameras selected!"),
"buttons": QtWidgets.QWidget(),
"okButton": QtWidgets.QPushButton("Ok"),
@@ -31,6 +35,9 @@ class CameraWindow(QtWidgets.QDialog):
for camera in cameras:
self.widgets["list"].addItem(camera)
+ self.widgets["staticImagePlane"].setText("Make Image Plane Static")
+ self.widgets["showInAllViews"].setText("Show Image Plane in All Views")
+
# Build buttons.
layout = QtWidgets.QHBoxLayout(self.widgets["buttons"])
layout.addWidget(self.widgets["okButton"])
@@ -40,6 +47,8 @@ class CameraWindow(QtWidgets.QDialog):
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(self.widgets["label"])
layout.addWidget(self.widgets["list"])
+ layout.addWidget(self.widgets["staticImagePlane"])
+ layout.addWidget(self.widgets["showInAllViews"])
layout.addWidget(self.widgets["buttons"])
layout.addWidget(self.widgets["warning"])
@@ -54,6 +63,8 @@ class CameraWindow(QtWidgets.QDialog):
if self.camera is None:
self.widgets["warning"].setVisible(True)
return
+ self.show_in_all_views = self.widgets["showInAllViews"].isChecked()
+ self.static_image_plane = self.widgets["staticImagePlane"].isChecked()
self.close()
@@ -65,15 +76,15 @@ class CameraWindow(QtWidgets.QDialog):
class ImagePlaneLoader(api.Loader):
"""Specific loader of plate for image planes on selected camera."""
- families = ["plate", "render"]
+ families = ["image", "plate", "render"]
label = "Load imagePlane."
representations = ["mov", "exr", "preview", "png"]
icon = "image"
color = "orange"
- def load(self, context, name, namespace, data):
+ def load(self, context, name, namespace, data, options=None):
import pymel.core as pm
-
+
new_nodes = []
image_plane_depth = 1000
asset = context['asset']['name']
@@ -85,17 +96,23 @@ class ImagePlaneLoader(api.Loader):
# Get camera from user selection.
camera = None
- default_cameras = [
- "frontShape", "perspShape", "sideShape", "topShape"
- ]
- cameras = [
- x for x in pm.ls(type="camera") if x.name() not in default_cameras
- ]
- camera_names = {x.getParent().name(): x for x in cameras}
- camera_names["Create new camera."] = "create_camera"
- window = CameraWindow(camera_names.keys())
- window.exec_()
- camera = camera_names[window.camera]
+ is_static_image_plane = None
+ is_in_all_views = None
+ if data:
+ camera = pm.PyNode(data.get("camera"))
+ is_static_image_plane = data.get("static_image_plane")
+ is_in_all_views = data.get("in_all_views")
+
+ if not camera:
+ cameras = pm.ls(type="camera")
+ camera_names = {x.getParent().name(): x for x in cameras}
+ camera_names["Create new camera."] = "create_camera"
+ window = CameraWindow(camera_names.keys())
+ window.exec_()
+ camera = camera_names[window.camera]
+
+ is_static_image_plane = window.static_image_plane
+ is_in_all_views = window.show_in_all_views
if camera == "create_camera":
camera = pm.createNode("camera")
@@ -111,13 +128,14 @@ class ImagePlaneLoader(api.Loader):
# Create image plane
image_plane_transform, image_plane_shape = pm.imagePlane(
- camera=camera, showInAllViews=False
+ fileName=context["representation"]["data"]["path"],
+ camera=camera, showInAllViews=is_in_all_views
)
image_plane_shape.depth.set(image_plane_depth)
- image_plane_shape.imageName.set(
- context["representation"]["data"]["path"]
- )
+ if is_static_image_plane:
+ image_plane_shape.detach()
+ image_plane_transform.setRotation(camera.getRotation())
start_frame = pm.playbackOptions(q=True, min=True)
end_frame = pm.playbackOptions(q=True, max=True)
diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py
index 53897b21f6..d39750e917 100644
--- a/openpype/hosts/maya/plugins/publish/collect_look.py
+++ b/openpype/hosts/maya/plugins/publish/collect_look.py
@@ -492,6 +492,8 @@ class CollectLook(pyblish.api.InstancePlugin):
if not cmds.attributeQuery(attr, node=node, exists=True):
continue
attribute = "{}.{}".format(node, attr)
+ if cmds.getAttr(attribute, type=True) == "message":
+ continue
node_attributes[attr] = cmds.getAttr(attribute)
attributes.append({"name": node,
diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py
index 580d459a90..ac1e495f08 100644
--- a/openpype/hosts/maya/plugins/publish/collect_render.py
+++ b/openpype/hosts/maya/plugins/publish/collect_render.py
@@ -192,7 +192,10 @@ class CollectMayaRender(pyblish.api.ContextPlugin):
render_products = layer_render_products.layer_data.products
assert render_products, "no render products generated"
exp_files = []
+ multipart = False
for product in render_products:
+ if product.multipart:
+ multipart = True
product_name = product.productName
if product.camera and layer_render_products.has_camera_token():
product_name = "{}{}".format(
@@ -205,7 +208,7 @@ class CollectMayaRender(pyblish.api.ContextPlugin):
})
self.log.info("multipart: {}".format(
- layer_render_products.multipart))
+ multipart))
assert exp_files, "no file names were generated, this is bug"
self.log.info(exp_files)
@@ -221,14 +224,19 @@ class CollectMayaRender(pyblish.api.ContextPlugin):
# append full path
full_exp_files = []
aov_dict = {}
-
+ default_render_file = context.data.get('project_settings')\
+ .get('maya')\
+ .get('create')\
+ .get('CreateRender')\
+ .get('default_render_image_folder')
# replace relative paths with absolute. Render products are
# returned as list of dictionaries.
publish_meta_path = None
for aov in exp_files:
full_paths = []
for file in aov[aov.keys()[0]]:
- full_path = os.path.join(workspace, "renders", file)
+ full_path = os.path.join(workspace, default_render_file,
+ file)
full_path = full_path.replace("\\", "/")
full_paths.append(full_path)
publish_meta_path = os.path.dirname(full_path)
@@ -300,7 +308,7 @@ class CollectMayaRender(pyblish.api.ContextPlugin):
"subset": expected_layer_name,
"attachTo": attach_to,
"setMembers": layer_name,
- "multipartExr": layer_render_products.multipart,
+ "multipartExr": multipart,
"review": render_instance.data.get("review") or False,
"publish": True,
diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py
index 2407617b6f..953539f65c 100644
--- a/openpype/hosts/maya/plugins/publish/extract_look.py
+++ b/openpype/hosts/maya/plugins/publish/extract_look.py
@@ -55,8 +55,16 @@ def maketx(source, destination, *args):
str: Output of `maketx` command.
"""
+ from openpype.lib import get_oiio_tools_path
+
+ maketx_path = get_oiio_tools_path("maketx")
+ if not os.path.exists(maketx_path):
+ print(
+ "OIIO tool not found in {}".format(maketx_path))
+ raise AssertionError("OIIO tool not found")
+
cmd = [
- "maketx",
+ maketx_path,
"-v", # verbose
"-u", # update mode
# unpremultiply before conversion (recommended when alpha present)
diff --git a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py
index dad1691149..642ca9e25d 100644
--- a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py
+++ b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py
@@ -23,11 +23,24 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin):
def process(self, instance):
- assert get_file_rule("images") == "renders", (
- "Workspace's `images` file rule must be set to: renders"
+ default_render_file = self.get_default_render_image_folder(instance)
+
+ assert get_file_rule("images") == default_render_file, (
+ "Workspace's `images` file rule must be set to: {}".format(
+ default_render_file
+ )
)
@classmethod
def repair(cls, instance):
- pm.workspace.fileRules["images"] = "renders"
+ default = cls.get_default_render_image_folder(instance)
+ pm.workspace.fileRules["images"] = default
pm.system.Workspace.save()
+
+ @staticmethod
+ def get_default_render_image_folder(instance):
+ return instance.context.data.get('project_settings')\
+ .get('maya') \
+ .get('create') \
+ .get('CreateRender') \
+ .get('default_render_image_folder')
diff --git a/openpype/hosts/nuke/api/__init__.py b/openpype/hosts/nuke/api/__init__.py
index e684b48fa3..1567189ed1 100644
--- a/openpype/hosts/nuke/api/__init__.py
+++ b/openpype/hosts/nuke/api/__init__.py
@@ -54,6 +54,10 @@ def install():
''' Installing all requarements for Nuke host
'''
+ # remove all registred callbacks form avalon.nuke
+ from avalon import pipeline
+ pipeline._registered_event_handlers.clear()
+
log.info("Registering Nuke plug-ins..")
pyblish.api.register_plugin_path(PUBLISH_PATH)
avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH)
@@ -62,6 +66,7 @@ def install():
# Register Avalon event for workfiles loading.
avalon.api.on("workio.open_file", lib.check_inventory_versions)
+ avalon.api.on("taskChanged", menu.change_context_label)
pyblish.api.register_callback(
"instanceToggled", on_pyblish_instance_toggled)
diff --git a/openpype/hosts/nuke/api/menu.py b/openpype/hosts/nuke/api/menu.py
index 4636098604..86293edb99 100644
--- a/openpype/hosts/nuke/api/menu.py
+++ b/openpype/hosts/nuke/api/menu.py
@@ -1,101 +1,128 @@
import os
import nuke
-from avalon.api import Session
from avalon.nuke.pipeline import get_main_window
from .lib import WorkfileSettings
from openpype.api import Logger, BuildWorkfile, get_current_project_settings
from openpype.tools.utils import host_tools
-from avalon.nuke.pipeline import get_main_window
log = Logger().get_logger(__name__)
menu_label = os.environ["AVALON_LABEL"]
+context_label = None
-def install():
- main_window = get_main_window()
+def change_context_label(*args):
+ global context_label
menubar = nuke.menu("Nuke")
menu = menubar.findItem(menu_label)
- # replace reset resolution from avalon core to pype's
- name = "Work Files..."
- rm_item = [
- (i, item) for i, item in enumerate(menu.items()) if name in item.name()
- ][0]
-
- log.debug("Changing Item: {}".format(rm_item))
-
- menu.removeItem(rm_item[1].name())
- menu.addCommand(
- name,
- lambda: host_tools.show_workfiles(parent=main_window),
- index=2
+ label = "{0}, {1}".format(
+ os.environ["AVALON_ASSET"], os.environ["AVALON_TASK"]
)
- menu.addSeparator(index=3)
- # replace reset resolution from avalon core to pype's
- name = "Reset Resolution"
- new_name = "Set Resolution"
+
rm_item = [
- (i, item) for i, item in enumerate(menu.items()) if name in item.name()
+ (i, item) for i, item in enumerate(menu.items())
+ if context_label in item.name()
][0]
- log.debug("Changing Item: {}".format(rm_item))
- # rm_item[1].setEnabled(False)
menu.removeItem(rm_item[1].name())
- menu.addCommand(
- new_name,
- lambda: WorkfileSettings().reset_resolution(),
+
+ context_action = menu.addCommand(
+ label,
index=(rm_item[0])
)
+ context_action.setEnabled(False)
- # replace reset frame range from avalon core to pype's
- name = "Reset Frame Range"
- new_name = "Set Frame Range"
- rm_item = [
- (i, item) for i, item in enumerate(menu.items()) if name in item.name()
- ][0]
- log.debug("Changing Item: {}".format(rm_item))
- # rm_item[1].setEnabled(False)
- menu.removeItem(rm_item[1].name())
- menu.addCommand(
- new_name,
- lambda: WorkfileSettings().reset_frame_range_handles(),
- index=(rm_item[0])
- )
+ log.info("Task label changed from `{}` to `{}`".format(
+ context_label, label))
- # add colorspace menu item
- name = "Set Colorspace"
- menu.addCommand(
- name, lambda: WorkfileSettings().set_colorspace()
- )
- log.debug("Adding menu item: {}".format(name))
+ context_label = label
- # add item that applies all setting above
- name = "Apply All Settings"
- menu.addCommand(
- name,
- lambda: WorkfileSettings().set_context_settings()
+
+
+def install():
+ from openpype.hosts.nuke.api import reload_config
+
+ global context_label
+
+ # uninstall original avalon menu
+ uninstall()
+
+ main_window = get_main_window()
+ menubar = nuke.menu("Nuke")
+ menu = menubar.addMenu(menu_label)
+
+ label = "{0}, {1}".format(
+ os.environ["AVALON_ASSET"], os.environ["AVALON_TASK"]
)
- log.debug("Adding menu item: {}".format(name))
+ context_label = label
+ context_action = menu.addCommand(label)
+ context_action.setEnabled(False)
menu.addSeparator()
-
- # add workfile builder menu item
- name = "Build Workfile"
menu.addCommand(
- name, lambda: BuildWorkfile().process()
+ "Work Files...",
+ lambda: host_tools.show_workfiles(parent=main_window)
+ )
+
+ menu.addSeparator()
+ menu.addCommand(
+ "Create...",
+ lambda: host_tools.show_creator(parent=main_window)
+ )
+ menu.addCommand(
+ "Load...",
+ lambda: host_tools.show_loader(
+ parent=main_window,
+ use_context=True
+ )
+ )
+ menu.addCommand(
+ "Publish...",
+ lambda: host_tools.show_publish(parent=main_window)
+ )
+ menu.addCommand(
+ "Manage...",
+ lambda: host_tools.show_scene_inventory(parent=main_window)
+ )
+
+ menu.addSeparator()
+ menu.addCommand(
+ "Set Resolution",
+ lambda: WorkfileSettings().reset_resolution()
+ )
+ menu.addCommand(
+ "Set Frame Range",
+ lambda: WorkfileSettings().reset_frame_range_handles()
+ )
+ menu.addCommand(
+ "Set Colorspace",
+ lambda: WorkfileSettings().set_colorspace()
+ )
+ menu.addCommand(
+ "Apply All Settings",
+ lambda: WorkfileSettings().set_context_settings()
+ )
+
+ menu.addSeparator()
+ menu.addCommand(
+ "Build Workfile",
+ lambda: BuildWorkfile().process()
)
- log.debug("Adding menu item: {}".format(name))
- # Add experimental tools action
menu.addSeparator()
menu.addCommand(
"Experimental tools...",
lambda: host_tools.show_experimental_tools_dialog(parent=main_window)
)
+ # add reload pipeline only in debug mode
+ if bool(os.getenv("NUKE_DEBUG")):
+ menu.addSeparator()
+ menu.addCommand("Reload Pipeline", reload_config)
+
# adding shortcuts
add_shortcuts_from_presets()
diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py
index 4ad2246e21..9ce72c0519 100644
--- a/openpype/hosts/nuke/plugins/load/load_clip.py
+++ b/openpype/hosts/nuke/plugins/load/load_clip.py
@@ -116,16 +116,7 @@ class LoadClip(plugin.NukeLoader):
"Representation id `{}` is failing to load".format(repr_id))
return
- name_data = {
- "asset": repr_cont["asset"],
- "subset": repr_cont["subset"],
- "representation": context["representation"]["name"],
- "ext": repr_cont["representation"],
- "id": context["representation"]["_id"],
- "class_name": self.__class__.__name__
- }
-
- read_name = self.node_name_template.format(**name_data)
+ read_name = self._get_node_name(context["representation"])
# Create the Loader with the filename path set
read_node = nuke.createNode(
@@ -143,7 +134,7 @@ class LoadClip(plugin.NukeLoader):
elif iio_colorspace is not None:
read_node["colorspace"].setValue(iio_colorspace)
- self.set_range_to_node(read_node, first, last, start_at_workfile)
+ self._set_range_to_node(read_node, first, last, start_at_workfile)
# add additional metadata from the version to imprint Avalon knob
add_keys = ["frameStart", "frameEnd",
@@ -171,7 +162,7 @@ class LoadClip(plugin.NukeLoader):
data=data_imprint)
if version_data.get("retime", None):
- self.make_retimes(read_node, version_data)
+ self._make_retimes(read_node, version_data)
self.set_as_member(read_node)
@@ -230,6 +221,9 @@ class LoadClip(plugin.NukeLoader):
"Representation id `{}` is failing to load".format(repr_id))
return
+ read_name = self._get_node_name(representation)
+
+ read_node["name"].setValue(read_name)
read_node["file"].setValue(file)
# to avoid multiple undo steps for rest of process
@@ -242,7 +236,7 @@ class LoadClip(plugin.NukeLoader):
elif iio_colorspace is not None:
read_node["colorspace"].setValue(iio_colorspace)
- self.set_range_to_node(read_node, first, last, start_at_workfile)
+ self._set_range_to_node(read_node, first, last, start_at_workfile)
updated_dict = {
"representation": str(representation["_id"]),
@@ -279,21 +273,12 @@ class LoadClip(plugin.NukeLoader):
self.log.info("udated to version: {}".format(version.get("name")))
if version_data.get("retime", None):
- self.make_retimes(read_node, version_data)
+ self._make_retimes(read_node, version_data)
else:
self.clear_members(read_node)
self.set_as_member(read_node)
- def set_range_to_node(self, read_node, first, last, start_at_workfile):
- read_node['origfirst'].setValue(int(first))
- read_node['first'].setValue(int(first))
- read_node['origlast'].setValue(int(last))
- read_node['last'].setValue(int(last))
-
- # set start frame depending on workfile or version
- self.loader_shift(read_node, start_at_workfile)
-
def remove(self, container):
from avalon.nuke import viewer_update_and_undo_stop
@@ -307,7 +292,16 @@ class LoadClip(plugin.NukeLoader):
for member in members:
nuke.delete(member)
- def make_retimes(self, parent_node, version_data):
+ def _set_range_to_node(self, read_node, first, last, start_at_workfile):
+ read_node['origfirst'].setValue(int(first))
+ read_node['first'].setValue(int(first))
+ read_node['origlast'].setValue(int(last))
+ read_node['last'].setValue(int(last))
+
+ # set start frame depending on workfile or version
+ self._loader_shift(read_node, start_at_workfile)
+
+ def _make_retimes(self, parent_node, version_data):
''' Create all retime and timewarping nodes with coppied animation '''
speed = version_data.get('speed', 1)
time_warp_nodes = version_data.get('timewarps', [])
@@ -360,7 +354,7 @@ class LoadClip(plugin.NukeLoader):
for i, n in enumerate(dependent_nodes):
last_node.setInput(i, n)
- def loader_shift(self, read_node, workfile_start=False):
+ def _loader_shift(self, read_node, workfile_start=False):
""" Set start frame of read node to a workfile start
Args:
@@ -371,3 +365,17 @@ class LoadClip(plugin.NukeLoader):
if workfile_start:
read_node['frame_mode'].setValue("start at")
read_node['frame'].setValue(str(self.script_start))
+
+ def _get_node_name(self, representation):
+
+ repr_cont = representation["context"]
+ name_data = {
+ "asset": repr_cont["asset"],
+ "subset": repr_cont["subset"],
+ "representation": representation["name"],
+ "ext": repr_cont["representation"],
+ "id": representation["_id"],
+ "class_name": self.__class__.__name__
+ }
+
+ return self.node_name_template.format(**name_data)
\ No newline at end of file
diff --git a/openpype/hosts/nuke/plugins/publish/extract_render_local.py b/openpype/hosts/nuke/plugins/publish/extract_render_local.py
index bc7b41c733..50a5d01483 100644
--- a/openpype/hosts/nuke/plugins/publish/extract_render_local.py
+++ b/openpype/hosts/nuke/plugins/publish/extract_render_local.py
@@ -42,10 +42,14 @@ class NukeRenderLocal(openpype.api.Extractor):
self.log.info("Start frame: {}".format(first_frame))
self.log.info("End frame: {}".format(last_frame))
+ # write node url might contain nuke's ctl expressin
+ # as [python ...]/path...
+ path = node["file"].evaluate()
+
# Ensure output directory exists.
- directory = os.path.dirname(node["file"].value())
- if not os.path.exists(directory):
- os.makedirs(directory)
+ out_dir = os.path.dirname(path)
+ if not os.path.exists(out_dir):
+ os.makedirs(out_dir)
# Render frames
nuke.execute(
@@ -58,15 +62,12 @@ class NukeRenderLocal(openpype.api.Extractor):
if "slate" in families:
first_frame += 1
- path = node['file'].value()
- out_dir = os.path.dirname(path)
ext = node["file_type"].value()
if "representations" not in instance.data:
instance.data["representations"] = []
collected_frames = os.listdir(out_dir)
-
if len(collected_frames) == 1:
repre = {
'name': ext,
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 261fca6583..32962b57a6 100644
--- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py
+++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py
@@ -42,6 +42,7 @@ class ExtractReviewDataMov(openpype.api.Extractor):
# generate data
with anlib.maintained_selection():
+ generated_repres = []
for o_name, o_data in self.outputs.items():
f_families = o_data["filter"]["families"]
f_task_types = o_data["filter"]["task_types"]
@@ -112,11 +113,13 @@ class ExtractReviewDataMov(openpype.api.Extractor):
})
else:
data = exporter.generate_mov(**o_data)
+ generated_repres.extend(data["representations"])
- self.log.info(data["representations"])
+ self.log.info(generated_repres)
- # assign to representations
- instance.data["representations"] += data["representations"]
+ if generated_repres:
+ # assign to representations
+ instance.data["representations"] += generated_repres
self.log.debug(
"_ representations: {}".format(
diff --git a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py
index 29faf867d2..af5e8e9d27 100644
--- a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py
+++ b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py
@@ -67,7 +67,9 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin):
if not repre.get("files"):
msg = ("no frames were collected, "
- "you need to render them")
+ "you need to render them.\n"
+ "Check properties of write node (group) and"
+ "select 'Local' option in 'Publish' dropdown.")
self.log.error(msg)
raise ValidationException(msg)
diff --git a/openpype/hosts/photoshop/plugins/publish/collect_current_file.py b/openpype/hosts/photoshop/plugins/publish/collect_current_file.py
index 3cc3e3f636..4d4829555e 100644
--- a/openpype/hosts/photoshop/plugins/publish/collect_current_file.py
+++ b/openpype/hosts/photoshop/plugins/publish/collect_current_file.py
@@ -8,7 +8,7 @@ from avalon import photoshop
class CollectCurrentFile(pyblish.api.ContextPlugin):
"""Inject the current working file into context"""
- order = pyblish.api.CollectorOrder - 0.5
+ order = pyblish.api.CollectorOrder - 0.49
label = "Current File"
hosts = ["photoshop"]
diff --git a/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py b/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py
new file mode 100644
index 0000000000..f07ff0b0ff
--- /dev/null
+++ b/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py
@@ -0,0 +1,57 @@
+import os
+import re
+import pyblish.api
+
+from avalon import photoshop
+
+
+class CollectExtensionVersion(pyblish.api.ContextPlugin):
+ """ Pulls and compares version of installed extension.
+
+ It is recommended to use same extension as in provided Openpype code.
+
+ Please use Anastasiyβs Extension Manager or ZXPInstaller to update
+ extension in case of an error.
+
+ You can locate extension.zxp in your installed Openpype code in
+ `repos/avalon-core/avalon/photoshop`
+ """
+ # This technically should be a validator, but other collectors might be
+ # impacted with usage of obsolete extension, so collector that runs first
+ # was chosen
+ order = pyblish.api.CollectorOrder - 0.5
+ label = "Collect extension version"
+ hosts = ["photoshop"]
+
+ optional = True
+ active = True
+
+ def process(self, context):
+ installed_version = photoshop.stub().get_extension_version()
+
+ if not installed_version:
+ raise ValueError("Unknown version, probably old extension")
+
+ manifest_url = os.path.join(os.path.dirname(photoshop.__file__),
+ "extension", "CSXS", "manifest.xml")
+
+ if not os.path.exists(manifest_url):
+ self.log.debug("Unable to locate extension manifest, not checking")
+ return
+
+ expected_version = None
+ with open(manifest_url) as fp:
+ content = fp.read()
+
+ found = re.findall(r'(ExtensionBundleVersion=")([0-10\.]+)(")',
+ content)
+ if found:
+ expected_version = found[0][1]
+
+ if expected_version != installed_version:
+ msg = "Expected version '{}' found '{}'\n".format(
+ expected_version, installed_version)
+ msg += "Please update your installed extension, it might not work "
+ msg += "properly."
+
+ raise ValueError(msg)
diff --git a/openpype/hosts/photoshop/plugins/publish/validate_naming.py b/openpype/hosts/photoshop/plugins/publish/validate_naming.py
index 0fd6794313..1635096f4b 100644
--- a/openpype/hosts/photoshop/plugins/publish/validate_naming.py
+++ b/openpype/hosts/photoshop/plugins/publish/validate_naming.py
@@ -1,3 +1,5 @@
+import re
+
import pyblish.api
import openpype.api
from avalon import photoshop
@@ -19,20 +21,33 @@ class ValidateNamingRepair(pyblish.api.Action):
and result["instance"] not in failed):
failed.append(result["instance"])
+ invalid_chars, replace_char = plugin.get_replace_chars()
+ self.log.info("{} --- {}".format(invalid_chars, replace_char))
+
# Apply pyblish.logic to get the instances for the plug-in
instances = pyblish.api.instances_by_plugin(failed, plugin)
stub = photoshop.stub()
for instance in instances:
self.log.info("validate_naming instance {}".format(instance))
- name = instance.data["name"].replace(" ", "_")
- name = name.replace(instance.data["family"], '')
- instance[0].Name = name
- data = stub.read(instance[0])
- data["subset"] = "image" + name
- stub.imprint(instance[0], data)
+ metadata = stub.read(instance[0])
+ self.log.info("metadata instance {}".format(metadata))
+ layer_name = None
+ if metadata.get("uuid"):
+ layer_data = stub.get_layer(metadata["uuid"])
+ self.log.info("layer_data {}".format(layer_data))
+ if layer_data:
+ layer_name = re.sub(invalid_chars,
+ replace_char,
+ layer_data.name)
- name = stub.PUBLISH_ICON + name
- stub.rename_layer(instance.data["uuid"], name)
+ stub.rename_layer(instance.data["uuid"], layer_name)
+
+ subset_name = re.sub(invalid_chars, replace_char,
+ instance.data["name"])
+
+ instance[0].Name = layer_name or subset_name
+ metadata["subset"] = subset_name
+ stub.imprint(instance[0], metadata)
return True
@@ -49,12 +64,21 @@ class ValidateNaming(pyblish.api.InstancePlugin):
families = ["image"]
actions = [ValidateNamingRepair]
+ # configured by Settings
+ invalid_chars = ''
+ replace_char = ''
+
def process(self, instance):
help_msg = ' Use Repair action (A) in Pyblish to fix it.'
msg = "Name \"{}\" is not allowed.{}".format(instance.data["name"],
help_msg)
- assert " " not in instance.data["name"], msg
+ assert not re.search(self.invalid_chars, instance.data["name"]), msg
msg = "Subset \"{}\" is not allowed.{}".format(instance.data["subset"],
help_msg)
- assert " " not in instance.data["subset"], msg
+ assert not re.search(self.invalid_chars, instance.data["subset"]), msg
+
+ @classmethod
+ def get_replace_chars(cls):
+ """Pass values configured in Settings for Repair."""
+ return cls.invalid_chars, cls.replace_char
diff --git a/openpype/hosts/unreal/api/tools_ui.py b/openpype/hosts/unreal/api/tools_ui.py
new file mode 100644
index 0000000000..93361c3574
--- /dev/null
+++ b/openpype/hosts/unreal/api/tools_ui.py
@@ -0,0 +1,158 @@
+import sys
+from Qt import QtWidgets, QtCore, QtGui
+
+from openpype import (
+ resources,
+ style
+)
+from openpype.tools.utils import host_tools
+from openpype.tools.utils.lib import qt_app_context
+
+
+class ToolsBtnsWidget(QtWidgets.QWidget):
+ """Widget containing buttons which are clickable."""
+ tool_required = QtCore.Signal(str)
+
+ def __init__(self, parent=None):
+ super(ToolsBtnsWidget, self).__init__(parent)
+
+ create_btn = QtWidgets.QPushButton("Create...", self)
+ load_btn = QtWidgets.QPushButton("Load...", self)
+ publish_btn = QtWidgets.QPushButton("Publish...", self)
+ manage_btn = QtWidgets.QPushButton("Manage...", self)
+ experimental_tools_btn = QtWidgets.QPushButton(
+ "Experimental tools...", self
+ )
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(create_btn, 0)
+ layout.addWidget(load_btn, 0)
+ layout.addWidget(publish_btn, 0)
+ layout.addWidget(manage_btn, 0)
+ layout.addWidget(experimental_tools_btn, 0)
+ layout.addStretch(1)
+
+ create_btn.clicked.connect(self._on_create)
+ load_btn.clicked.connect(self._on_load)
+ publish_btn.clicked.connect(self._on_publish)
+ manage_btn.clicked.connect(self._on_manage)
+ experimental_tools_btn.clicked.connect(self._on_experimental)
+
+ def _on_create(self):
+ self.tool_required.emit("creator")
+
+ def _on_load(self):
+ self.tool_required.emit("loader")
+
+ def _on_publish(self):
+ self.tool_required.emit("publish")
+
+ def _on_manage(self):
+ self.tool_required.emit("sceneinventory")
+
+ def _on_experimental(self):
+ self.tool_required.emit("experimental_tools")
+
+
+class ToolsDialog(QtWidgets.QDialog):
+ """Dialog with tool buttons that will stay opened until user close it."""
+ def __init__(self, *args, **kwargs):
+ super(ToolsDialog, self).__init__(*args, **kwargs)
+
+ self.setWindowTitle("OpenPype tools")
+ icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
+ self.setWindowIcon(icon)
+
+ self.setWindowFlags(
+ QtCore.Qt.Window
+ | QtCore.Qt.WindowStaysOnTopHint
+ )
+ self.setFocusPolicy(QtCore.Qt.StrongFocus)
+
+ tools_widget = ToolsBtnsWidget(self)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(tools_widget)
+
+ tools_widget.tool_required.connect(self._on_tool_require)
+ self._tools_widget = tools_widget
+
+ self._first_show = True
+
+ def sizeHint(self):
+ result = super(ToolsDialog, self).sizeHint()
+ result.setWidth(result.width() * 2)
+ return result
+
+ def showEvent(self, event):
+ super(ToolsDialog, self).showEvent(event)
+ if self._first_show:
+ self.setStyleSheet(style.load_stylesheet())
+ self._first_show = False
+
+ def _on_tool_require(self, tool_name):
+ host_tools.show_tool_by_name(tool_name, parent=self)
+
+
+class ToolsPopup(ToolsDialog):
+ """Popup with tool buttons that will close when loose focus."""
+ def __init__(self, *args, **kwargs):
+ super(ToolsPopup, self).__init__(*args, **kwargs)
+
+ self.setWindowFlags(
+ QtCore.Qt.FramelessWindowHint
+ | QtCore.Qt.Popup
+ )
+
+ def showEvent(self, event):
+ super(ToolsPopup, self).showEvent(event)
+ app = QtWidgets.QApplication.instance()
+ app.processEvents()
+ pos = QtGui.QCursor.pos()
+ self.move(pos)
+
+
+class WindowCache:
+ """Cached objects and methods to be used in global scope."""
+ _dialog = None
+ _popup = None
+ _first_show = True
+
+ @classmethod
+ def _before_show(cls):
+ """Create QApplication if does not exists yet."""
+ if not cls._first_show:
+ return
+
+ cls._first_show = False
+ if not QtWidgets.QApplication.instance():
+ QtWidgets.QApplication(sys.argv)
+
+ @classmethod
+ def show_popup(cls):
+ cls._before_show()
+ with qt_app_context():
+ if cls._popup is None:
+ cls._popup = ToolsPopup()
+
+ cls._popup.show()
+
+ @classmethod
+ def show_dialog(cls):
+ cls._before_show()
+ with qt_app_context():
+ if cls._dialog is None:
+ cls._dialog = ToolsDialog()
+
+ cls._dialog.show()
+ cls._dialog.raise_()
+ cls._dialog.activateWindow()
+
+
+def show_tools_popup():
+ WindowCache.show_popup()
+
+
+def show_tools_dialog():
+ WindowCache.show_dialog()
diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_tvpaint_instances.py b/openpype/hosts/webpublisher/plugins/publish/collect_tvpaint_instances.py
index c533403e5f..976a14e808 100644
--- a/openpype/hosts/webpublisher/plugins/publish/collect_tvpaint_instances.py
+++ b/openpype/hosts/webpublisher/plugins/publish/collect_tvpaint_instances.py
@@ -138,7 +138,7 @@ class CollectTVPaintInstances(pyblish.api.ContextPlugin):
"family": "render"
}
subset_name = get_subset_name_with_asset_doc(
- self.render_pass_family,
+ self.render_layer_family,
variant,
task_name,
asset_doc,
@@ -223,7 +223,7 @@ class CollectTVPaintInstances(pyblish.api.ContextPlugin):
"name": subset_name,
"subset": subset_name,
"label": subset_name,
- "family": self.render_pass_family,
+ "family": "render",
# Add `review` family for thumbnail integration
"families": [self.render_pass_family, "review"],
"representations": [],
@@ -239,9 +239,9 @@ class CollectTVPaintInstances(pyblish.api.ContextPlugin):
"name": subset_name,
"subset": subset_name,
"label": subset_name,
- "family": self.render_pass_family,
+ "family": "render",
# Add `review` family for thumbnail integration
- "families": [self.render_pass_family, "review"],
+ "families": [self.render_layer_family, "review"],
"representations": [],
"layers": layers,
"stagingDir": staging_dir
diff --git a/openpype/hosts/webpublisher/plugins/publish/validate_tvpaint_workfile_data.py b/openpype/hosts/webpublisher/plugins/publish/validate_tvpaint_workfile_data.py
index eec6ef1004..a5e4868411 100644
--- a/openpype/hosts/webpublisher/plugins/publish/validate_tvpaint_workfile_data.py
+++ b/openpype/hosts/webpublisher/plugins/publish/validate_tvpaint_workfile_data.py
@@ -10,6 +10,7 @@ class ValidateWorkfileData(pyblish.api.ContextPlugin):
label = "Validate Workfile Data"
order = pyblish.api.ValidatorOrder
+ targets = ["tvpaint_worker"]
def process(self, context):
# Data collected in `CollectAvalonEntities`
diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py
index ee4821b80d..c99e3bc28d 100644
--- a/openpype/lib/__init__.py
+++ b/openpype/lib/__init__.py
@@ -32,8 +32,6 @@ from .execute import (
)
from .log import PypeLogger, timeit
from .mongo import (
- decompose_url,
- compose_url,
get_default_components,
validate_mongo_connection,
OpenPypeMongoConnection
@@ -49,7 +47,8 @@ from .vendor_bin_utils import (
get_vendor_bin_path,
get_oiio_tools_path,
get_ffmpeg_tool_path,
- ffprobe_streams
+ ffprobe_streams,
+ is_oiio_supported
)
from .python_module_tools import (
@@ -65,6 +64,11 @@ from .profiles_filtering import (
filter_profiles
)
+from .transcoding import (
+ get_transcode_temp_directory,
+ should_convert_for_ffmpeg,
+ convert_for_ffmpeg
+)
from .avalon_context import (
CURRENT_DOC_SCHEMAS,
PROJECT_NAME_ALLOWED_SYMBOLS,
@@ -137,10 +141,6 @@ from .plugin_tools import (
source_hash,
get_unique_layer_name,
get_background_layers,
- oiio_supported,
- decompress,
- get_decompress_dir,
- should_decompress
)
from .path_tools import (
@@ -185,6 +185,7 @@ __all__ = [
"get_oiio_tools_path",
"get_ffmpeg_tool_path",
"ffprobe_streams",
+ "is_oiio_supported",
"import_filepath",
"modules_from_path",
@@ -192,6 +193,10 @@ __all__ = [
"classes_from_module",
"import_module_from_dirpath",
+ "get_transcode_temp_directory",
+ "should_convert_for_ffmpeg",
+ "convert_for_ffmpeg",
+
"CURRENT_DOC_SCHEMAS",
"PROJECT_NAME_ALLOWED_SYMBOLS",
"PROJECT_NAME_REGEX",
@@ -256,10 +261,6 @@ __all__ = [
"source_hash",
"get_unique_layer_name",
"get_background_layers",
- "oiio_supported",
- "decompress",
- "get_decompress_dir",
- "should_decompress",
"version_up",
"get_version_from_path",
@@ -273,8 +274,6 @@ __all__ = [
"get_datetime_data",
"PypeLogger",
- "decompose_url",
- "compose_url",
"get_default_components",
"validate_mongo_connection",
"OpenPypeMongoConnection",
diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py
index 30be92e886..86cf0229df 100644
--- a/openpype/lib/applications.py
+++ b/openpype/lib/applications.py
@@ -41,6 +41,97 @@ from .python_module_tools import (
_logger = None
+PLATFORM_NAMES = {"windows", "linux", "darwin"}
+DEFAULT_ENV_SUBGROUP = "standard"
+
+
+def parse_environments(env_data, env_group=None, platform_name=None):
+ """Parse environment values from settings byt group and platfrom.
+
+ Data may contain up to 2 hierarchical levels of dictionaries. At the end
+ of the last level must be string or list. List is joined using platform
+ specific joiner (';' for windows and ':' for linux and mac).
+
+ Hierarchical levels can contain keys for subgroups and platform name.
+ Platform specific values must be always last level of dictionary. Platform
+ names are "windows" (MS Windows), "linux" (any linux distribution) and
+ "darwin" (any MacOS distribution).
+
+ Subgroups are helpers added mainly for standard and on farm usage. Farm
+ may require different environments for e.g. licence related values or
+ plugins. Default subgroup is "standard".
+
+ Examples:
+ ```
+ {
+ # Unchanged value
+ "ENV_KEY1": "value",
+ # Empty values are kept (unset environment variable)
+ "ENV_KEY2": "",
+
+ # Join list values with ':' or ';'
+ "ENV_KEY3": ["value1", "value2"],
+
+ # Environment groups
+ "ENV_KEY4": {
+ "standard": "DEMO_SERVER_URL",
+ "farm": "LICENCE_SERVER_URL"
+ },
+
+ # Platform specific (and only for windows and mac)
+ "ENV_KEY5": {
+ "windows": "windows value",
+ "darwin": ["value 1", "value 2"]
+ },
+
+ # Environment groups and platform combination
+ "ENV_KEY6": {
+ "farm": "FARM_VALUE",
+ "standard": {
+ "windows": ["value1", "value2"],
+ "linux": "value1",
+ "darwin": ""
+ }
+ }
+ }
+ ```
+ """
+ output = {}
+ if not env_data:
+ return output
+
+ if not env_group:
+ env_group = DEFAULT_ENV_SUBGROUP
+
+ if not platform_name:
+ platform_name = platform.system().lower()
+
+ for key, value in env_data.items():
+ if isinstance(value, dict):
+ # Look if any key is platform key
+ # - expect that represents environment group if does not contain
+ # platform keys
+ if not PLATFORM_NAMES.intersection(set(value.keys())):
+ # Skip the key if group is not available
+ if env_group not in value:
+ continue
+ value = value[env_group]
+
+ # Check again if value is dictionary
+ # - this time there should be only platform keys
+ if isinstance(value, dict):
+ value = value.get(platform_name)
+
+ # Check if value is list and join it's values
+ # QUESTION Should empty values be skipped?
+ if isinstance(value, (list, tuple)):
+ value = os.pathsep.join(value)
+
+ # Set key to output if value is string
+ if isinstance(value, six.string_types):
+ output[key] = value
+ return output
+
def get_logger():
"""Global lib.applications logger getter."""
@@ -640,6 +731,10 @@ class LaunchHook:
def app_name(self):
return getattr(self.application, "full_name", None)
+ @property
+ def modules_manager(self):
+ return getattr(self.launch_context, "modules_manager", None)
+
def validate(self):
"""Optional validation of launch hook on initialization.
@@ -701,21 +796,32 @@ class ApplicationLaunchContext:
preparation to store objects usable in multiple places.
"""
- def __init__(self, application, executable, **data):
+ def __init__(self, application, executable, env_group=None, **data):
+ from openpype.modules import ModulesManager
+
# Application object
self.application = application
+ self.modules_manager = ModulesManager()
+
# Logger
logger_name = "{}-{}".format(self.__class__.__name__, self.app_name)
self.log = PypeLogger.get_logger(logger_name)
self.executable = executable
+ if env_group is None:
+ env_group = DEFAULT_ENV_SUBGROUP
+
+ self.env_group = env_group
+
self.data = dict(data)
# subprocess.Popen launch arguments (first argument in constructor)
self.launch_args = executable.as_args()
self.launch_args.extend(application.arguments)
+ if self.data.get("app_args"):
+ self.launch_args.extend(self.data.pop("app_args"))
# Handle launch environemtns
env = self.data.pop("env", None)
@@ -810,10 +916,7 @@ class ApplicationLaunchContext:
paths.append(path)
# Load modules paths
- from openpype.modules import ModulesManager
-
- manager = ModulesManager()
- paths.extend(manager.collect_launch_hook_paths())
+ paths.extend(self.modules_manager.collect_launch_hook_paths())
return paths
@@ -1045,7 +1148,7 @@ class EnvironmentPrepData(dict):
def get_app_environments_for_context(
- project_name, asset_name, task_name, app_name, env=None
+ project_name, asset_name, task_name, app_name, env_group=None, env=None
):
"""Prepare environment variables by context.
Args:
@@ -1097,8 +1200,8 @@ def get_app_environments_for_context(
"env": env
})
- prepare_host_environments(data)
- prepare_context_environments(data)
+ prepare_host_environments(data, env_group)
+ prepare_context_environments(data, env_group)
# Discard avalon connection
dbcon.uninstall()
@@ -1118,7 +1221,7 @@ def _merge_env(env, current_env):
return result
-def prepare_host_environments(data, implementation_envs=True):
+def prepare_host_environments(data, env_group=None, implementation_envs=True):
"""Modify launch environments based on launched app and context.
Args:
@@ -1172,7 +1275,7 @@ def prepare_host_environments(data, implementation_envs=True):
continue
# Choose right platform
- tool_env = acre.parse(_env_values)
+ tool_env = parse_environments(_env_values, env_group)
# Merge dictionaries
env_values = _merge_env(tool_env, env_values)
@@ -1204,7 +1307,9 @@ def prepare_host_environments(data, implementation_envs=True):
data["env"].pop(key, None)
-def apply_project_environments_value(project_name, env, project_settings=None):
+def apply_project_environments_value(
+ project_name, env, project_settings=None, env_group=None
+):
"""Apply project specific environments on passed environments.
The enviornments are applied on passed `env` argument value so it is not
@@ -1232,14 +1337,15 @@ def apply_project_environments_value(project_name, env, project_settings=None):
env_value = project_settings["global"]["project_environments"]
if env_value:
+ parsed_value = parse_environments(env_value, env_group)
env.update(acre.compute(
- _merge_env(acre.parse(env_value), env),
+ _merge_env(parsed_value, env),
cleanup=False
))
return env
-def prepare_context_environments(data):
+def prepare_context_environments(data, env_group=None):
"""Modify launch environemnts with context data for launched host.
Args:
@@ -1269,7 +1375,7 @@ def prepare_context_environments(data):
data["project_settings"] = project_settings
# Apply project specific environments on current env value
apply_project_environments_value(
- project_name, data["env"], project_settings
+ project_name, data["env"], project_settings, env_group
)
app = data["app"]
diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py
index a8340d7d09..cb5bca133d 100644
--- a/openpype/lib/avalon_context.py
+++ b/openpype/lib/avalon_context.py
@@ -508,13 +508,18 @@ def get_workdir_data(project_doc, asset_doc, task_name, host_name):
Returns:
dict: Data prepared for filling workdir template.
"""
- hierarchy = "/".join(asset_doc["data"]["parents"])
-
task_type = asset_doc['data']['tasks'].get(task_name, {}).get('type')
project_task_types = project_doc["config"]["tasks"]
task_code = project_task_types.get(task_type, {}).get("short_name")
+ asset_parents = asset_doc["data"]["parents"]
+ hierarchy = "/".join(asset_parents)
+
+ parent_name = project_doc["name"]
+ if asset_parents:
+ parent_name = asset_parents[-1]
+
data = {
"project": {
"name": project_doc["name"],
@@ -526,6 +531,7 @@ def get_workdir_data(project_doc, asset_doc, task_name, host_name):
"short": task_code,
},
"asset": asset_doc["name"],
+ "parent": parent_name,
"app": host_name,
"user": getpass.getuser(),
"hierarchy": hierarchy,
@@ -1427,7 +1433,11 @@ def get_creator_by_name(creator_name, case_sensitive=False):
@with_avalon
def change_timer_to_current_context():
- """Called after context change to change timers"""
+ """Called after context change to change timers.
+
+ TODO:
+ - use TimersManager's static method instead of reimplementing it here
+ """
webserver_url = os.environ.get("OPENPYPE_WEBSERVER_URL")
if not webserver_url:
log.warning("Couldn't find webserver url")
@@ -1442,8 +1452,7 @@ def change_timer_to_current_context():
data = {
"project_name": avalon.io.Session["AVALON_PROJECT"],
"asset_name": avalon.io.Session["AVALON_ASSET"],
- "task_name": avalon.io.Session["AVALON_TASK"],
- "hierarchy": get_hierarchy()
+ "task_name": avalon.io.Session["AVALON_TASK"]
}
requests.post(rest_api_url, json=data)
diff --git a/openpype/lib/log.py b/openpype/lib/log.py
index 85cbc733ba..a42faef008 100644
--- a/openpype/lib/log.py
+++ b/openpype/lib/log.py
@@ -27,7 +27,7 @@ import copy
from . import Terminal
from .mongo import (
MongoEnvNotSet,
- decompose_url,
+ get_default_components,
OpenPypeMongoConnection
)
try:
@@ -202,8 +202,9 @@ class PypeLogger:
use_mongo_logging = None
mongo_process_id = None
- # Information about mongo url
- log_mongo_url = None
+ # Backwards compatibility - was used in start.py
+ # TODO remove when all old builds are replaced with new one
+ # not using 'log_mongo_url_components'
log_mongo_url_components = None
# Database name in Mongo
@@ -282,9 +283,9 @@ class PypeLogger:
if not cls.use_mongo_logging:
return
- components = cls.log_mongo_url_components
+ components = get_default_components()
kwargs = {
- "host": cls.log_mongo_url,
+ "host": components["host"],
"database_name": cls.log_database_name,
"collection": cls.log_collection_name,
"username": components["username"],
@@ -324,6 +325,7 @@ class PypeLogger:
# Change initialization state to prevent runtime changes
# if is executed during runtime
cls.initialized = False
+ cls.log_mongo_url_components = get_default_components()
# Define if should logging to mongo be used
use_mongo_logging = bool(log4mongo is not None)
@@ -354,14 +356,8 @@ class PypeLogger:
# Define if is in OPENPYPE_DEBUG mode
cls.pype_debug = int(os.getenv("OPENPYPE_DEBUG") or "0")
- # Mongo URL where logs will be stored
- cls.log_mongo_url = os.environ.get("OPENPYPE_MONGO")
-
- if not cls.log_mongo_url:
+ if not os.environ.get("OPENPYPE_MONGO"):
cls.use_mongo_logging = False
- else:
- # Decompose url
- cls.log_mongo_url_components = decompose_url(cls.log_mongo_url)
# Mark as initialized
cls.initialized = True
@@ -474,7 +470,7 @@ class PypeLogger:
if not cls.initialized:
cls.initialize()
- return OpenPypeMongoConnection.get_mongo_client(cls.log_mongo_url)
+ return OpenPypeMongoConnection.get_mongo_client()
def timeit(method):
diff --git a/openpype/lib/mongo.py b/openpype/lib/mongo.py
index 0fd4517b5b..7e0bd4f796 100644
--- a/openpype/lib/mongo.py
+++ b/openpype/lib/mongo.py
@@ -15,7 +15,19 @@ class MongoEnvNotSet(Exception):
pass
-def decompose_url(url):
+def _decompose_url(url):
+ """Decompose mongo url to basic components.
+
+ Used for creation of MongoHandler which expect mongo url components as
+ separated kwargs. Components are at the end not used as we're setting
+ connection directly this is just a dumb components for MongoHandler
+ validation pass.
+ """
+ # Use first url from passed url
+ # - this is beacuse it is possible to pass multiple urls for multiple
+ # replica sets which would crash on urlparse otherwise
+ # - please don't use comma in username of password
+ url = url.split(",")[0]
components = {
"scheme": None,
"host": None,
@@ -48,42 +60,13 @@ def decompose_url(url):
return components
-def compose_url(scheme=None,
- host=None,
- username=None,
- password=None,
- port=None,
- auth_db=None):
-
- url = "{scheme}://"
-
- if username and password:
- url += "{username}:{password}@"
-
- url += "{host}"
- if port:
- url += ":{port}"
-
- if auth_db:
- url += "?authSource={auth_db}"
-
- return url.format(**{
- "scheme": scheme,
- "host": host,
- "username": username,
- "password": password,
- "port": port,
- "auth_db": auth_db
- })
-
-
def get_default_components():
mongo_url = os.environ.get("OPENPYPE_MONGO")
if mongo_url is None:
raise MongoEnvNotSet(
"URL for Mongo logging connection is not set."
)
- return decompose_url(mongo_url)
+ return _decompose_url(mongo_url)
def should_add_certificate_path_to_mongo_url(mongo_url):
diff --git a/openpype/lib/path_tools.py b/openpype/lib/path_tools.py
index 6fd0ad0dfe..9bb0231ca7 100644
--- a/openpype/lib/path_tools.py
+++ b/openpype/lib/path_tools.py
@@ -307,7 +307,6 @@ class HostDirmap:
mapping = {}
if not project_settings["global"]["sync_server"]["enabled"]:
- log.debug("Site Sync not enabled")
return mapping
from openpype.settings.lib import get_site_local_overrides
diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py
index 891163e3ae..7c66f9760d 100644
--- a/openpype/lib/plugin_tools.py
+++ b/openpype/lib/plugin_tools.py
@@ -5,12 +5,8 @@ import inspect
import logging
import re
import json
-import tempfile
-import distutils
-from .execute import run_subprocess
from .profiles_filtering import filter_profiles
-from .vendor_bin_utils import get_oiio_tools_path
from openpype.settings import get_project_settings
@@ -231,20 +227,27 @@ def filter_pyblish_plugins(plugins):
# iterate over plugins
for plugin in plugins[:]:
- file = os.path.normpath(inspect.getsourcefile(plugin))
- file = os.path.normpath(file)
-
- # host determined from path
- host_from_file = file.split(os.path.sep)[-4:-3][0]
- plugin_kind = file.split(os.path.sep)[-2:-1][0]
-
- # TODO: change after all plugins are moved one level up
- if host_from_file == "openpype":
- host_from_file = "global"
-
try:
config_data = presets[host]["publish"][plugin.__name__]
except KeyError:
+ # host determined from path
+ file = os.path.normpath(inspect.getsourcefile(plugin))
+ file = os.path.normpath(file)
+
+ split_path = file.split(os.path.sep)
+ if len(split_path) < 4:
+ log.warning(
+ 'plugin path too short to extract host {}'.format(file)
+ )
+ continue
+
+ host_from_file = split_path[-4]
+ plugin_kind = split_path[-2]
+
+ # TODO: change after all plugins are moved one level up
+ if host_from_file == "openpype":
+ host_from_file = "global"
+
try:
config_data = presets[host_from_file][plugin_kind][plugin.__name__] # noqa: E501
except KeyError:
@@ -425,129 +428,6 @@ def get_background_layers(file_url):
return layers
-def oiio_supported():
- """
- Checks if oiiotool is configured for this platform.
-
- Triggers simple subprocess, handles exception if fails.
-
- 'should_decompress' will throw exception if configured,
- but not present or not working.
- Returns:
- (bool)
- """
- oiio_path = get_oiio_tools_path()
- if oiio_path:
- oiio_path = distutils.spawn.find_executable(oiio_path)
-
- if not oiio_path:
- log.debug("OIIOTool is not configured or not present at {}".
- format(oiio_path))
- return False
-
- return True
-
-
-def decompress(target_dir, file_url,
- input_frame_start=None, input_frame_end=None, log=None):
- """
- Decompresses DWAA 'file_url' .exr to 'target_dir'.
-
- Creates uncompressed files in 'target_dir', they need to be cleaned.
-
- File url could be for single file or for a sequence, in that case
- %0Xd will be as a placeholder for frame number AND input_frame* will
- be filled.
- In that case single oiio command with '--frames' will be triggered for
- all frames, this should be faster then looping and running sequentially
-
- Args:
- target_dir (str): extended from stagingDir
- file_url (str): full urls to source file (with or without %0Xd)
- input_frame_start (int) (optional): first frame
- input_frame_end (int) (optional): last frame
- log (Logger) (optional): pype logger
- """
- is_sequence = input_frame_start is not None and \
- input_frame_end is not None and \
- (int(input_frame_end) > int(input_frame_start))
-
- oiio_cmd = []
- oiio_cmd.append(get_oiio_tools_path())
-
- oiio_cmd.append("--compression none")
-
- base_file_name = os.path.basename(file_url)
- oiio_cmd.append(file_url)
-
- if is_sequence:
- oiio_cmd.append("--frames {}-{}".format(input_frame_start,
- input_frame_end))
-
- oiio_cmd.append("-o")
- oiio_cmd.append(os.path.join(target_dir, base_file_name))
-
- subprocess_exr = " ".join(oiio_cmd)
-
- if not log:
- log = logging.getLogger(__name__)
-
- log.debug("Decompressing {}".format(subprocess_exr))
- run_subprocess(
- subprocess_exr, shell=True, logger=log
- )
-
-
-def get_decompress_dir():
- """
- Creates temporary folder for decompressing.
- Its local, in case of farm it is 'local' to the farm machine.
-
- Should be much faster, needs to be cleaned up later.
- """
- return os.path.normpath(
- tempfile.mkdtemp(prefix="pyblish_tmp_")
- )
-
-
-def should_decompress(file_url):
- """
- Tests that 'file_url' is compressed with DWAA.
-
- Uses 'oiio_supported' to check that OIIO tool is available for this
- platform.
-
- Shouldn't throw exception as oiiotool is guarded by check function.
- Currently implemented this way as there is no support for Mac and Linux
- In the future, it should be more strict and throws exception on
- misconfiguration.
-
- Args:
- file_url (str): path to rendered file (in sequence it would be
- first file, if that compressed it is expected that whole seq
- will be too)
- Returns:
- (bool): 'file_url' is DWAA compressed and should be decompressed
- and we can decompress (oiiotool supported)
- """
- if oiio_supported():
- try:
- output = run_subprocess([
- get_oiio_tools_path(),
- "--info", "-v", file_url])
- return "compression: \"dwaa\"" in output or \
- "compression: \"dwab\"" in output
- except RuntimeError:
- _name, ext = os.path.splitext(file_url)
- # TODO: should't the list of allowed extensions be
- # taken from an OIIO variable of supported formats
- if ext not in [".mxf"]:
- # Reraise exception
- raise
- return False
- return False
-
-
def parse_json(path):
"""Parses json file at 'path' location
diff --git a/openpype/lib/remote_publish.py b/openpype/lib/remote_publish.py
index d7db4d1ab9..8074b2d112 100644
--- a/openpype/lib/remote_publish.py
+++ b/openpype/lib/remote_publish.py
@@ -2,6 +2,7 @@ import os
from datetime import datetime
import sys
from bson.objectid import ObjectId
+import collections
import pyblish.util
import pyblish.api
@@ -11,6 +12,25 @@ from openpype.lib.mongo import OpenPypeMongoConnection
from openpype.lib.plugin_tools import parse_json
+def headless_publish(log, close_plugin_name=None, is_test=False):
+ """Runs publish in a opened host with a context and closes Python process.
+
+ Host is being closed via ClosePS pyblish plugin which triggers 'exit'
+ method in ConsoleTrayApp.
+ """
+ if not is_test:
+ dbcon = get_webpublish_conn()
+ _id = os.environ.get("BATCH_LOG_ID")
+ if not _id:
+ log.warning("Unable to store log records, "
+ "batch will be unfinished!")
+ return
+
+ publish_and_log(dbcon, _id, log, close_plugin_name)
+ else:
+ publish(log, close_plugin_name)
+
+
def get_webpublish_conn():
"""Get connection to OP 'webpublishes' collection."""
mongo_client = OpenPypeMongoConnection.get_mongo_client()
@@ -37,6 +57,33 @@ def start_webpublish_log(dbcon, batch_id, user):
}).inserted_id
+def publish(log, close_plugin_name=None):
+ """Loops through all plugins, logs to console. Used for tests.
+
+ Args:
+ log (OpenPypeLogger)
+ close_plugin_name (str): name of plugin with responsibility to
+ close host app
+ """
+ # Error exit as soon as any error occurs.
+ error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}"
+
+ close_plugin = _get_close_plugin(close_plugin_name, log)
+
+ for result in pyblish.util.publish_iter():
+ for record in result["records"]:
+ log.info("{}: {}".format(
+ result["plugin"].label, record.msg))
+
+ if result["error"]:
+ log.error(error_format.format(**result))
+ uninstall()
+ if close_plugin: # close host app explicitly after error
+ context = pyblish.api.Context()
+ close_plugin().process(context)
+ sys.exit(1)
+
+
def publish_and_log(dbcon, _id, log, close_plugin_name=None):
"""Loops through all plugins, logs ok and fails into OP DB.
@@ -140,7 +187,9 @@ def find_variant_key(application_manager, host):
found_variant_key = None
# finds most up-to-date variant if any installed
- for variant_key, variant in app_group.variants.items():
+ sorted_variants = collections.OrderedDict(
+ sorted(app_group.variants.items()))
+ for variant_key, variant in sorted_variants.items():
for executable in variant.executables:
if executable.exists():
found_variant_key = variant_key
diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py
new file mode 100644
index 0000000000..3d587e2f29
--- /dev/null
+++ b/openpype/lib/transcoding.py
@@ -0,0 +1,266 @@
+import os
+import re
+import logging
+import collections
+import tempfile
+
+from .execute import run_subprocess
+from .vendor_bin_utils import (
+ get_oiio_tools_path,
+ is_oiio_supported
+)
+
+
+def get_transcode_temp_directory():
+ """Creates temporary folder for transcoding.
+
+ Its local, in case of farm it is 'local' to the farm machine.
+
+ Should be much faster, needs to be cleaned up later.
+ """
+ return os.path.normpath(
+ tempfile.mkdtemp(prefix="op_transcoding_")
+ )
+
+
+def get_oiio_info_for_input(filepath, logger=None):
+ """Call oiiotool to get information about input and return stdout."""
+ args = [
+ get_oiio_tools_path(), "--info", "-v", filepath
+ ]
+ return run_subprocess(args, logger=logger)
+
+
+def parse_oiio_info(oiio_info):
+ """Create an object based on output from oiiotool.
+
+ Removes quotation marks from compression value. Parse channels into
+ dictionary - key is channel name value is determined type of channel
+ (e.g. 'uint', 'float').
+
+ Args:
+ oiio_info (str): Output of calling "oiiotool --info -v "
+
+ Returns:
+ dict: Loaded data from output.
+ """
+ lines = [
+ line.strip()
+ for line in oiio_info.split("\n")
+ ]
+ # Each line should contain information about one key
+ # key - value are separated with ": "
+ oiio_sep = ": "
+ data_map = {}
+ for line in lines:
+ parts = line.split(oiio_sep)
+ if len(parts) < 2:
+ continue
+ key = parts.pop(0)
+ value = oiio_sep.join(parts)
+ data_map[key] = value
+
+ if "compression" in data_map:
+ value = data_map["compression"]
+ data_map["compression"] = value.replace("\"", "")
+
+ channels_info = {}
+ channels_value = data_map.get("channel list") or ""
+ if channels_value:
+ channels = channels_value.split(", ")
+ type_regex = re.compile(r"(?P[^\(]+) \((?P[^\)]+)\)")
+ for channel in channels:
+ match = type_regex.search(channel)
+ if not match:
+ channel_name = channel
+ channel_type = "uint"
+ else:
+ channel_name = match.group("name")
+ channel_type = match.group("type")
+ channels_info[channel_name] = channel_type
+ data_map["channels_info"] = channels_info
+ return data_map
+
+
+def get_convert_rgb_channels(channels_info):
+ """Get first available RGB(A) group from channels info.
+
+ ## Examples
+ ```
+ # Ideal situation
+ channels_info: {
+ "R": ...,
+ "G": ...,
+ "B": ...,
+ "A": ...
+ }
+ ```
+ Result will be `("R", "G", "B", "A")`
+
+ ```
+ # Not ideal situation
+ channels_info: {
+ "beauty.red": ...,
+ "beuaty.green": ...,
+ "beauty.blue": ...,
+ "depth.Z": ...
+ }
+ ```
+ Result will be `("beauty.red", "beauty.green", "beauty.blue", None)`
+
+ Returns:
+ NoneType: There is not channel combination that matches RGB
+ combination.
+ tuple: Tuple of 4 channel names defying channel names for R, G, B, A
+ where A can be None.
+ """
+ rgb_by_main_name = collections.defaultdict(dict)
+ main_name_order = [""]
+ for channel_name in channels_info.keys():
+ name_parts = channel_name.split(".")
+ rgb_part = name_parts.pop(-1).lower()
+ main_name = ".".join(name_parts)
+ if rgb_part in ("r", "red"):
+ rgb_by_main_name[main_name]["R"] = channel_name
+ elif rgb_part in ("g", "green"):
+ rgb_by_main_name[main_name]["G"] = channel_name
+ elif rgb_part in ("b", "blue"):
+ rgb_by_main_name[main_name]["B"] = channel_name
+ elif rgb_part in ("a", "alpha"):
+ rgb_by_main_name[main_name]["A"] = channel_name
+ else:
+ continue
+ if main_name not in main_name_order:
+ main_name_order.append(main_name)
+
+ output = None
+ for main_name in main_name_order:
+ colors = rgb_by_main_name.get(main_name) or {}
+ red = colors.get("R")
+ green = colors.get("G")
+ blue = colors.get("B")
+ alpha = colors.get("A")
+ if red is not None and green is not None and blue is not None:
+ output = (red, green, blue, alpha)
+ break
+
+ return output
+
+
+def should_convert_for_ffmpeg(src_filepath):
+ """Find out if input should be converted for ffmpeg.
+
+ Currently cares only about exr inputs and is based on OpenImageIO.
+
+ Returns:
+ bool/NoneType: True if should be converted, False if should not and
+ None if can't determine.
+ """
+ # Care only about exr at this moment
+ ext = os.path.splitext(src_filepath)[-1].lower()
+ if ext != ".exr":
+ return False
+
+ # Can't determine if should convert or not without oiio_tool
+ if not is_oiio_supported():
+ return None
+
+ # Load info about info from oiio tool
+ oiio_info = get_oiio_info_for_input(src_filepath)
+ input_info = parse_oiio_info(oiio_info)
+
+ # Check compression
+ compression = input_info["compression"]
+ if compression in ("dwaa", "dwab"):
+ return True
+
+ # Check channels
+ channels_info = input_info["channels_info"]
+ review_channels = get_convert_rgb_channels(channels_info)
+ if review_channels is None:
+ return None
+
+ return False
+
+
+def convert_for_ffmpeg(
+ first_input_path,
+ output_dir,
+ input_frame_start,
+ input_frame_end,
+ logger=None
+):
+ """Contert source file to format supported in ffmpeg.
+
+ Currently can convert only exrs.
+
+ Args:
+ first_input_path (str): Path to first file of a sequence or a single
+ file path for non-sequential input.
+ output_dir (str): Path to directory where output will be rendered.
+ Must not be same as input's directory.
+ input_frame_start (int): Frame start of input.
+ input_frame_end (int): Frame end of input.
+ logger (logging.Logger): Logger used for logging.
+
+ Raises:
+ ValueError: If input filepath has extension not supported by function.
+ Currently is supported only ".exr" extension.
+ """
+ if logger is None:
+ logger = logging.getLogger(__name__)
+
+ ext = os.path.splitext(first_input_path)[1].lower()
+ if ext != ".exr":
+ raise ValueError((
+ "Function 'convert_for_ffmpeg' currently support only"
+ " \".exr\" extension. Got \"{}\"."
+ ).format(ext))
+
+ is_sequence = False
+ if input_frame_start is not None and input_frame_end is not None:
+ is_sequence = int(input_frame_end) != int(input_frame_start)
+
+ oiio_info = get_oiio_info_for_input(first_input_path)
+ input_info = parse_oiio_info(oiio_info)
+
+ # Change compression only if source compression is "dwaa" or "dwab"
+ # - they're not supported in ffmpeg
+ compression = input_info["compression"]
+ if compression in ("dwaa", "dwab"):
+ compression = "none"
+
+ # Prepare subprocess arguments
+ oiio_cmd = [
+ get_oiio_tools_path(),
+ "--compression", compression,
+ first_input_path
+ ]
+
+ channels_info = input_info["channels_info"]
+ review_channels = get_convert_rgb_channels(channels_info)
+ if review_channels is None:
+ raise ValueError(
+ "Couldn't find channels that can be used for conversion."
+ )
+
+ red, green, blue, alpha = review_channels
+ channels_arg = "R={},G={},B={}".format(red, green, blue)
+ if alpha is not None:
+ channels_arg += ",A={}".format(alpha)
+ oiio_cmd.append("--ch")
+ oiio_cmd.append(channels_arg)
+
+ # Add frame definitions to arguments
+ if is_sequence:
+ oiio_cmd.append("--frames")
+ oiio_cmd.append("{}-{}".format(input_frame_start, input_frame_end))
+
+ # Add last argument - path to output
+ base_file_name = os.path.basename(first_input_path)
+ output_path = os.path.join(output_dir, base_file_name)
+ oiio_cmd.append("-o")
+ oiio_cmd.append(output_path)
+
+ logger.debug("Conversion command: {}".format(" ".join(oiio_cmd)))
+ run_subprocess(oiio_cmd, logger=logger)
diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py
index 42f2b34bb2..a5d4153b2a 100644
--- a/openpype/lib/vendor_bin_utils.py
+++ b/openpype/lib/vendor_bin_utils.py
@@ -3,6 +3,7 @@ import logging
import json
import platform
import subprocess
+import distutils
log = logging.getLogger("FFmpeg utils")
@@ -105,3 +106,21 @@ def ffprobe_streams(path_to_file, logger=None):
))
return json.loads(popen_stdout)["streams"]
+
+
+def is_oiio_supported():
+ """Checks if oiiotool is configured for this platform.
+
+ Returns:
+ bool: OIIO tool executable is available.
+ """
+ loaded_path = oiio_path = get_oiio_tools_path()
+ if oiio_path:
+ oiio_path = distutils.spawn.find_executable(oiio_path)
+
+ if not oiio_path:
+ log.debug("OIIOTool is not configured or not present at {}".format(
+ loaded_path
+ ))
+ return False
+ return True
diff --git a/openpype/modules/default_modules/avalon_apps/__init__.py b/openpype/modules/avalon_apps/__init__.py
similarity index 100%
rename from openpype/modules/default_modules/avalon_apps/__init__.py
rename to openpype/modules/avalon_apps/__init__.py
diff --git a/openpype/modules/default_modules/avalon_apps/avalon_app.py b/openpype/modules/avalon_apps/avalon_app.py
similarity index 100%
rename from openpype/modules/default_modules/avalon_apps/avalon_app.py
rename to openpype/modules/avalon_apps/avalon_app.py
diff --git a/openpype/modules/default_modules/avalon_apps/rest_api.py b/openpype/modules/avalon_apps/rest_api.py
similarity index 100%
rename from openpype/modules/default_modules/avalon_apps/rest_api.py
rename to openpype/modules/avalon_apps/rest_api.py
diff --git a/openpype/modules/base.py b/openpype/modules/base.py
index 7ecfeae7bd..b5c491a1c0 100644
--- a/openpype/modules/base.py
+++ b/openpype/modules/base.py
@@ -29,6 +29,22 @@ from openpype.settings.lib import (
from openpype.lib import PypeLogger
+DEFAULT_OPENPYPE_MODULES = (
+ "avalon_apps",
+ "clockify",
+ "log_viewer",
+ "muster",
+ "python_console_interpreter",
+ "slack",
+ "webserver",
+ "launcher_action",
+ "project_manager_action",
+ "settings_action",
+ "standalonepublish_action",
+ "job_queue",
+)
+
+
# Inherit from `object` for Python 2 hosts
class _ModuleClass(object):
"""Fake module class for storing OpenPype modules.
@@ -272,17 +288,12 @@ def _load_modules():
log = PypeLogger.get_logger("ModulesLoader")
# Import default modules imported from 'openpype.modules'
- for default_module_name in (
- "settings_action",
- "launcher_action",
- "project_manager_action",
- "standalonepublish_action",
- ):
+ for default_module_name in DEFAULT_OPENPYPE_MODULES:
try:
- default_module = __import__(
- "openpype.modules.{}".format(default_module_name),
- fromlist=("", )
- )
+ import_str = "openpype.modules.{}".format(default_module_name)
+ new_import_str = "{}.{}".format(modules_key, default_module_name)
+ default_module = __import__(import_str, fromlist=("", ))
+ sys.modules[new_import_str] = default_module
setattr(openpype_modules, default_module_name, default_module)
except Exception:
diff --git a/openpype/modules/default_modules/clockify/__init__.py b/openpype/modules/clockify/__init__.py
similarity index 100%
rename from openpype/modules/default_modules/clockify/__init__.py
rename to openpype/modules/clockify/__init__.py
diff --git a/openpype/modules/default_modules/clockify/clockify_api.py b/openpype/modules/clockify/clockify_api.py
similarity index 100%
rename from openpype/modules/default_modules/clockify/clockify_api.py
rename to openpype/modules/clockify/clockify_api.py
diff --git a/openpype/modules/default_modules/clockify/clockify_module.py b/openpype/modules/clockify/clockify_module.py
similarity index 100%
rename from openpype/modules/default_modules/clockify/clockify_module.py
rename to openpype/modules/clockify/clockify_module.py
diff --git a/openpype/modules/default_modules/clockify/constants.py b/openpype/modules/clockify/constants.py
similarity index 100%
rename from openpype/modules/default_modules/clockify/constants.py
rename to openpype/modules/clockify/constants.py
diff --git a/openpype/modules/default_modules/clockify/ftrack/server/action_clockify_sync_server.py b/openpype/modules/clockify/ftrack/server/action_clockify_sync_server.py
similarity index 100%
rename from openpype/modules/default_modules/clockify/ftrack/server/action_clockify_sync_server.py
rename to openpype/modules/clockify/ftrack/server/action_clockify_sync_server.py
diff --git a/openpype/modules/default_modules/clockify/ftrack/user/action_clockify_sync_local.py b/openpype/modules/clockify/ftrack/user/action_clockify_sync_local.py
similarity index 100%
rename from openpype/modules/default_modules/clockify/ftrack/user/action_clockify_sync_local.py
rename to openpype/modules/clockify/ftrack/user/action_clockify_sync_local.py
diff --git a/openpype/modules/default_modules/clockify/launcher_actions/ClockifyStart.py b/openpype/modules/clockify/launcher_actions/ClockifyStart.py
similarity index 100%
rename from openpype/modules/default_modules/clockify/launcher_actions/ClockifyStart.py
rename to openpype/modules/clockify/launcher_actions/ClockifyStart.py
diff --git a/openpype/modules/default_modules/clockify/launcher_actions/ClockifySync.py b/openpype/modules/clockify/launcher_actions/ClockifySync.py
similarity index 100%
rename from openpype/modules/default_modules/clockify/launcher_actions/ClockifySync.py
rename to openpype/modules/clockify/launcher_actions/ClockifySync.py
diff --git a/openpype/modules/default_modules/clockify/widgets.py b/openpype/modules/clockify/widgets.py
similarity index 100%
rename from openpype/modules/default_modules/clockify/widgets.py
rename to openpype/modules/clockify/widgets.py
diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py
index e6c42374ca..51a19e2aad 100644
--- a/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py
+++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py
@@ -394,9 +394,14 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin):
self.log.debug(filepath)
# Gather needed data ------------------------------------------------
+ default_render_file = instance.context.data.get('project_settings')\
+ .get('maya')\
+ .get('create')\
+ .get('CreateRender')\
+ .get('default_render_image_folder')
filename = os.path.basename(filepath)
comment = context.data.get("comment", "")
- dirname = os.path.join(workspace, "renders")
+ dirname = os.path.join(workspace, default_render_file)
renderlayer = instance.data['setMembers'] # rs_beauty
deadline_user = context.data.get("user", getpass.getuser())
jobname = "%s - %s" % (filename, instance.name)
diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py
index 1e158bda9b..516bd755d0 100644
--- a/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py
+++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py
@@ -445,9 +445,14 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
preview = True
break
+ if instance_data.get("multipartExr"):
+ preview = True
+
new_instance = copy(instance_data)
new_instance["subset"] = subset_name
new_instance["subsetGroup"] = group_name
+ if preview:
+ new_instance["review"] = True
# create represenation
if isinstance(col, (list, tuple)):
@@ -527,6 +532,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
if bake_renders:
preview = False
+ # toggle preview on if multipart is on
+ if instance.get("multipartExr", False):
+ preview = True
+
staging = os.path.dirname(list(collection)[0])
success, rootless_staging_dir = (
self.anatomy.find_root_template_from_path(staging)
diff --git a/openpype/modules/default_modules/ftrack/launch_hooks/post_ftrack_changes.py b/openpype/modules/default_modules/ftrack/launch_hooks/post_ftrack_changes.py
index df16cde2b8..d5a95fad91 100644
--- a/openpype/modules/default_modules/ftrack/launch_hooks/post_ftrack_changes.py
+++ b/openpype/modules/default_modules/ftrack/launch_hooks/post_ftrack_changes.py
@@ -52,7 +52,7 @@ class PostFtrackHook(PostLaunchHook):
)
if entity:
self.ftrack_status_change(session, entity, project_name)
- self.start_timer(session, entity, ftrack_api)
+
except Exception:
self.log.warning(
"Couldn't finish Ftrack procedure.", exc_info=True
@@ -160,26 +160,3 @@ class PostFtrackHook(PostLaunchHook):
" on Ftrack entity type \"{}\""
).format(next_status_name, entity.entity_type)
self.log.warning(msg)
-
- def start_timer(self, session, entity, _ftrack_api):
- """Start Ftrack timer on task from context."""
- self.log.debug("Triggering timer start.")
-
- user_entity = session.query("User where username is \"{}\"".format(
- os.environ["FTRACK_API_USER"]
- )).first()
- if not user_entity:
- self.log.warning(
- "Couldn't find user with username \"{}\" in Ftrack".format(
- os.environ["FTRACK_API_USER"]
- )
- )
- return
-
- try:
- user_entity.start_timer(entity, force=True)
- session.commit()
- self.log.debug("Timer start triggered successfully.")
-
- except Exception:
- self.log.warning("Couldn't trigger Ftrack timer.", exc_info=True)
diff --git a/openpype/modules/default_modules/ftrack/lib/avalon_sync.py b/openpype/modules/default_modules/ftrack/lib/avalon_sync.py
index 3ba874281a..f58eb91485 100644
--- a/openpype/modules/default_modules/ftrack/lib/avalon_sync.py
+++ b/openpype/modules/default_modules/ftrack/lib/avalon_sync.py
@@ -360,6 +360,8 @@ class SyncEntitiesFactory:
self._subsets_by_parent_id = None
self._changeability_by_mongo_id = None
+ self._object_types_by_name = None
+
self.all_filtered_entities = {}
self.filtered_ids = []
self.not_selected_ids = []
@@ -651,6 +653,18 @@ class SyncEntitiesFactory:
self._bubble_changeability(list(self.subsets_by_parent_id.keys()))
return self._changeability_by_mongo_id
+ @property
+ def object_types_by_name(self):
+ if self._object_types_by_name is None:
+ object_types_by_name = self.session.query(
+ "select id, name from ObjectType"
+ ).all()
+ self._object_types_by_name = {
+ object_type["name"]: object_type
+ for object_type in object_types_by_name
+ }
+ return self._object_types_by_name
+
@property
def all_ftrack_names(self):
"""
@@ -880,10 +894,7 @@ class SyncEntitiesFactory:
custom_attrs, hier_attrs = get_openpype_attr(
self.session, query_keys=self.cust_attr_query_keys
)
- ent_types = self.session.query("select id, name from ObjectType").all()
- ent_types_by_name = {
- ent_type["name"]: ent_type["id"] for ent_type in ent_types
- }
+ ent_types_by_name = self.object_types_by_name
# Custom attribute types
cust_attr_types = self.session.query(
"select id, name from CustomAttributeType"
@@ -2491,7 +2502,13 @@ class SyncEntitiesFactory:
parent_entity = self.entities_dict[parent_id]["entity"]
_name = av_entity["name"]
- _type = av_entity["data"].get("entityType", "folder")
+ _type = av_entity["data"].get("entityType")
+ # Check existence of object type
+ if _type and _type not in self.object_types_by_name:
+ _type = None
+
+ if not _type:
+ _type = "Folder"
self.log.debug((
"Re-ceating deleted entity {} <{}>"
diff --git a/openpype/modules/default_modules/royal_render/plugins/publish/collect_default_rr_path.py b/openpype/modules/default_modules/royal_render/plugins/publish/collect_default_rr_path.py
index cdca03bef0..3ce95e0c50 100644
--- a/openpype/modules/default_modules/royal_render/plugins/publish/collect_default_rr_path.py
+++ b/openpype/modules/default_modules/royal_render/plugins/publish/collect_default_rr_path.py
@@ -6,7 +6,7 @@ import pyblish.api
class CollectDefaultRRPath(pyblish.api.ContextPlugin):
"""Collect default Royal Render path."""
- order = pyblish.api.CollectorOrder + 0.01
+ order = pyblish.api.CollectorOrder
label = "Default Royal Render Path"
def process(self, context):
diff --git a/openpype/modules/default_modules/royal_render/plugins/publish/collect_rr_path_from_instance.py b/openpype/modules/default_modules/royal_render/plugins/publish/collect_rr_path_from_instance.py
index fb27a76d11..6a3dc276f3 100644
--- a/openpype/modules/default_modules/royal_render/plugins/publish/collect_rr_path_from_instance.py
+++ b/openpype/modules/default_modules/royal_render/plugins/publish/collect_rr_path_from_instance.py
@@ -5,7 +5,7 @@ import pyblish.api
class CollectRRPathFromInstance(pyblish.api.InstancePlugin):
"""Collect RR Path from instance."""
- order = pyblish.api.CollectorOrder
+ order = pyblish.api.CollectorOrder + 0.01
label = "Royal Render Path from the Instance"
families = ["rendering"]
@@ -38,8 +38,8 @@ class CollectRRPathFromInstance(pyblish.api.InstancePlugin):
if k in default_servers
}
- except AttributeError:
- # Handle situation were we had only one url for deadline.
+ except (AttributeError, KeyError):
+ # Handle situation were we had only one url for royal render.
return render_instance.context.data["defaultRRPath"]
return rr_servers[
diff --git a/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py b/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py
index 2505d671af..b389b022cf 100644
--- a/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py
+++ b/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py
@@ -168,9 +168,6 @@ class CollectSequencesFromJob(pyblish.api.ContextPlugin):
start = data.get("frameStart", indices[0])
end = data.get("frameEnd", indices[-1])
- # root = os.path.normpath(root)
- # self.log.info("Source: {}}".format(data.get("source", "")))
-
ext = list(collection)[0].split('.')[-1]
instance.data.update({
@@ -195,6 +192,8 @@ class CollectSequencesFromJob(pyblish.api.ContextPlugin):
'name': ext,
'ext': '{}'.format(ext),
'files': list(collection),
+ "frameStart": start,
+ "frameEnd": end,
"stagingDir": root,
"anatomy_template": "render",
"fps": fps,
diff --git a/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py b/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py
index 290f26a44a..7fedb51410 100644
--- a/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py
+++ b/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py
@@ -38,28 +38,20 @@ class OpenPypeContextSelector:
os.environ.get("PROGRAMFILES"),
"OpenPype", "openpype_console.exe"
)
- if os.path.exists(op_path):
- print(" - found OpenPype installation {}".format(op_path))
- else:
+ if not os.path.exists(op_path):
# try to find in user local context
op_path = os.path.join(
os.environ.get("LOCALAPPDATA"),
"Programs",
"OpenPype", "openpype_console.exe"
)
- if os.path.exists(op_path):
- print(
- " - found OpenPype installation {}".format(
- op_path))
- else:
+ if not os.path.exists(op_path):
raise Exception("Error: OpenPype was not found.")
- self.openpype_root = op_path
+ op_path = os.path.dirname(op_path)
+ print(" - found OpenPype installation {}".format(op_path))
- # TODO: this should try to find metadata file. Either using
- # jobs custom attributes or using environment variable
- # or just using plain existence of file.
- # self.context = self._process_metadata_file()
+ self.openpype_root = op_path
def _process_metadata_file(self):
"""Find and process metadata file.
@@ -86,8 +78,8 @@ class OpenPypeContextSelector:
automatically, no UI will be show and publishing will directly
proceed.
"""
- if not self.context:
- self.show()
+ if not self.context and not self.show():
+ return
self.context["user"] = self.job.userName
self.run_publish()
@@ -120,10 +112,15 @@ class OpenPypeContextSelector:
not self.context.get("asset") or \
not self.context.get("task"):
self._show_rr_warning("Context selection failed.")
- return
+ return False
# self.context["app_name"] = self.job.renderer.name
+ # there should be mapping between OpenPype and Royal Render
+ # app names and versions, but since app_name is not used
+ # currently down the line (but it is required by OP publish command
+ # right now).
self.context["app_name"] = "maya/2020"
+ return True
@staticmethod
def _show_rr_warning(text):
diff --git a/openpype/modules/default_modules/timers_manager/exceptions.py b/openpype/modules/default_modules/timers_manager/exceptions.py
new file mode 100644
index 0000000000..5a9e00765d
--- /dev/null
+++ b/openpype/modules/default_modules/timers_manager/exceptions.py
@@ -0,0 +1,3 @@
+class InvalidContextError(ValueError):
+ """Context for which the timer should be started is invalid."""
+ pass
diff --git a/openpype/modules/default_modules/timers_manager/launch_hooks/post_start_timer.py b/openpype/modules/default_modules/timers_manager/launch_hooks/post_start_timer.py
new file mode 100644
index 0000000000..d6ae013403
--- /dev/null
+++ b/openpype/modules/default_modules/timers_manager/launch_hooks/post_start_timer.py
@@ -0,0 +1,45 @@
+from openpype.lib import PostLaunchHook
+
+
+class PostStartTimerHook(PostLaunchHook):
+ """Start timer with TimersManager module.
+
+ This module requires enabled TimerManager module.
+ """
+ order = None
+
+ def execute(self):
+ project_name = self.data.get("project_name")
+ asset_name = self.data.get("asset_name")
+ task_name = self.data.get("task_name")
+
+ missing_context_keys = set()
+ if not project_name:
+ missing_context_keys.add("project_name")
+ if not asset_name:
+ missing_context_keys.add("asset_name")
+ if not task_name:
+ missing_context_keys.add("task_name")
+
+ if missing_context_keys:
+ missing_keys_str = ", ".join([
+ "\"{}\"".format(key) for key in missing_context_keys
+ ])
+ self.log.debug("Hook {} skipped. Missing data keys: {}".format(
+ self.__class__.__name__, missing_keys_str
+ ))
+ return
+
+ timers_manager = self.modules_manager.modules_by_name.get(
+ "timers_manager"
+ )
+ if not timers_manager or not timers_manager.enabled:
+ self.log.info((
+ "Skipping starting timer because"
+ " TimersManager is not available."
+ ))
+ return
+
+ timers_manager.start_timer_with_webserver(
+ project_name, asset_name, task_name, logger=self.log
+ )
diff --git a/openpype/modules/default_modules/timers_manager/rest_api.py b/openpype/modules/default_modules/timers_manager/rest_api.py
index 19b72d688b..f16cb316c3 100644
--- a/openpype/modules/default_modules/timers_manager/rest_api.py
+++ b/openpype/modules/default_modules/timers_manager/rest_api.py
@@ -39,17 +39,23 @@ class TimersManagerModuleRestApi:
async def start_timer(self, request):
data = await request.json()
try:
- project_name = data['project_name']
- asset_name = data['asset_name']
- task_name = data['task_name']
- hierarchy = data['hierarchy']
+ project_name = data["project_name"]
+ asset_name = data["asset_name"]
+ task_name = data["task_name"]
except KeyError:
- log.error("Payload must contain fields 'project_name, " +
- "'asset_name', 'task_name', 'hierarchy'")
- return Response(status=400)
+ msg = (
+ "Payload must contain fields 'project_name,"
+ " 'asset_name' and 'task_name'"
+ )
+ log.error(msg)
+ return Response(status=400, message=msg)
self.module.stop_timers()
- self.module.start_timer(project_name, asset_name, task_name, hierarchy)
+ try:
+ self.module.start_timer(project_name, asset_name, task_name)
+ except Exception as exc:
+ return Response(status=404, message=str(exc))
+
return Response(status=200)
async def stop_timer(self, request):
diff --git a/openpype/modules/default_modules/timers_manager/timers_manager.py b/openpype/modules/default_modules/timers_manager/timers_manager.py
index 0f165ff0ac..47d020104b 100644
--- a/openpype/modules/default_modules/timers_manager/timers_manager.py
+++ b/openpype/modules/default_modules/timers_manager/timers_manager.py
@@ -1,9 +1,15 @@
import os
import platform
-from openpype.modules import OpenPypeModule
-from openpype_interfaces import ITrayService
+
from avalon.api import AvalonMongoDB
+from openpype.modules import OpenPypeModule
+from openpype_interfaces import (
+ ITrayService,
+ ILaunchHookPaths
+)
+from .exceptions import InvalidContextError
+
class ExampleTimersManagerConnector:
"""Timers manager can handle timers of multiple modules/addons.
@@ -64,7 +70,7 @@ class ExampleTimersManagerConnector:
self._timers_manager_module.timer_stopped(self._module.id)
-class TimersManager(OpenPypeModule, ITrayService):
+class TimersManager(OpenPypeModule, ITrayService, ILaunchHookPaths):
""" Handles about Timers.
Should be able to start/stop all timers at once.
@@ -151,47 +157,112 @@ class TimersManager(OpenPypeModule, ITrayService):
self._idle_manager.stop()
self._idle_manager.wait()
- def start_timer(self, project_name, asset_name, task_name, hierarchy):
- """
- Start timer for 'project_name', 'asset_name' and 'task_name'
+ def get_timer_data_for_path(self, task_path):
+ """Convert string path to a timer data.
- Called from REST api by hosts.
-
- Args:
- project_name (string)
- asset_name (string)
- task_name (string)
- hierarchy (string)
+ It is expected that first item is project name, last item is task name
+ and parent asset name is before task name.
"""
+ path_items = task_path.split("/")
+ if len(path_items) < 3:
+ raise InvalidContextError("Invalid path \"{}\"".format(task_path))
+ task_name = path_items.pop(-1)
+ asset_name = path_items.pop(-1)
+ project_name = path_items.pop(0)
+ return self.get_timer_data_for_context(
+ project_name, asset_name, task_name, self.log
+ )
+
+ def get_launch_hook_paths(self):
+ """Implementation of `ILaunchHookPaths`."""
+ return os.path.join(
+ os.path.dirname(os.path.abspath(__file__)),
+ "launch_hooks"
+ )
+
+ @staticmethod
+ def get_timer_data_for_context(
+ project_name, asset_name, task_name, logger=None
+ ):
+ """Prepare data for timer related callbacks.
+
+ TODO:
+ - return predefined object that has access to asset document etc.
+ """
+ if not project_name or not asset_name or not task_name:
+ raise InvalidContextError((
+ "Missing context information got"
+ " Project: \"{}\" Asset: \"{}\" Task: \"{}\""
+ ).format(str(project_name), str(asset_name), str(task_name)))
+
dbconn = AvalonMongoDB()
dbconn.install()
dbconn.Session["AVALON_PROJECT"] = project_name
- asset_doc = dbconn.find_one({
- "type": "asset", "name": asset_name
- })
+ asset_doc = dbconn.find_one(
+ {
+ "type": "asset",
+ "name": asset_name
+ },
+ {
+ "data.tasks": True,
+ "data.parents": True
+ }
+ )
if not asset_doc:
- raise ValueError("Uknown asset {}".format(asset_name))
+ dbconn.uninstall()
+ raise InvalidContextError((
+ "Asset \"{}\" not found in project \"{}\""
+ ).format(asset_name, project_name))
- task_type = ''
+ asset_data = asset_doc.get("data") or {}
+ asset_tasks = asset_data.get("tasks") or {}
+ if task_name not in asset_tasks:
+ dbconn.uninstall()
+ raise InvalidContextError((
+ "Task \"{}\" not found on asset \"{}\" in project \"{}\""
+ ).format(task_name, asset_name, project_name))
+
+ task_type = ""
try:
- task_type = asset_doc["data"]["tasks"][task_name]["type"]
+ task_type = asset_tasks[task_name]["type"]
except KeyError:
- self.log.warning("Couldn't find task_type for {}".
- format(task_name))
+ msg = "Couldn't find task_type for {}".format(task_name)
+ if logger is not None:
+ logger.warning(msg)
+ else:
+ print(msg)
- hierarchy = hierarchy.split("\\")
- hierarchy.append(asset_name)
+ hierarchy_items = asset_data.get("parents") or []
+ hierarchy_items.append(asset_name)
- data = {
+ dbconn.uninstall()
+ return {
"project_name": project_name,
"task_name": task_name,
"task_type": task_type,
- "hierarchy": hierarchy
+ "hierarchy": hierarchy_items
}
+
+ def start_timer(self, project_name, asset_name, task_name):
+ """Start timer for passed context.
+
+ Args:
+ project_name (str): Project name
+ asset_name (str): Asset name
+ task_name (str): Task name
+ """
+ data = self.get_timer_data_for_context(
+ project_name, asset_name, task_name, self.log
+ )
self.timer_started(None, data)
def get_task_time(self, project_name, asset_name, task_name):
+ """Get total time for passed context.
+
+ TODO:
+ - convert context to timer data
+ """
times = {}
for module_id, connector in self._connectors_by_module_id.items():
if hasattr(connector, "get_task_time"):
@@ -202,6 +273,10 @@ class TimersManager(OpenPypeModule, ITrayService):
return times
def timer_started(self, source_id, data):
+ """Connector triggered that timer has started.
+
+ New timer has started for context in data.
+ """
for module_id, connector in self._connectors_by_module_id.items():
if module_id == source_id:
continue
@@ -219,6 +294,14 @@ class TimersManager(OpenPypeModule, ITrayService):
self.is_running = True
def timer_stopped(self, source_id):
+ """Connector triggered that hist timer has stopped.
+
+ Should stop all other timers.
+
+ TODO:
+ - pass context for which timer has stopped to validate if timers are
+ same and valid
+ """
for module_id, connector in self._connectors_by_module_id.items():
if module_id == source_id:
continue
@@ -237,6 +320,7 @@ class TimersManager(OpenPypeModule, ITrayService):
self.timer_started(None, self.last_task)
def stop_timers(self):
+ """Stop all timers."""
if self.is_running is False:
return
@@ -295,18 +379,40 @@ class TimersManager(OpenPypeModule, ITrayService):
self, server_manager
)
- def change_timer_from_host(self, project_name, asset_name, task_name):
- """Prepared method for calling change timers on REST api"""
+ @staticmethod
+ def start_timer_with_webserver(
+ project_name, asset_name, task_name, logger=None
+ ):
+ """Prepared method for calling change timers on REST api.
+
+ Webserver must be active. At the moment is Webserver running only when
+ OpenPype Tray is used.
+
+ Args:
+ project_name (str): Project name.
+ asset_name (str): Asset name.
+ task_name (str): Task name.
+ logger (logging.Logger): Logger object. Using 'print' if not
+ passed.
+ """
webserver_url = os.environ.get("OPENPYPE_WEBSERVER_URL")
if not webserver_url:
- self.log.warning("Couldn't find webserver url")
+ msg = "Couldn't find webserver url"
+ if logger is not None:
+ logger.warning(msg)
+ else:
+ print(msg)
return
rest_api_url = "{}/timers_manager/start_timer".format(webserver_url)
try:
import requests
except Exception:
- self.log.warning("Couldn't start timer")
+ msg = "Couldn't start timer ('requests' is not available)"
+ if logger is not None:
+ logger.warning(msg)
+ else:
+ print(msg)
return
data = {
"project_name": project_name,
@@ -314,4 +420,4 @@ class TimersManager(OpenPypeModule, ITrayService):
"task_name": task_name
}
- requests.post(rest_api_url, json=data)
+ return requests.post(rest_api_url, json=data)
diff --git a/openpype/modules/default_modules/job_queue/__init__.py b/openpype/modules/job_queue/__init__.py
similarity index 100%
rename from openpype/modules/default_modules/job_queue/__init__.py
rename to openpype/modules/job_queue/__init__.py
diff --git a/openpype/modules/default_modules/job_queue/job_server/__init__.py b/openpype/modules/job_queue/job_server/__init__.py
similarity index 100%
rename from openpype/modules/default_modules/job_queue/job_server/__init__.py
rename to openpype/modules/job_queue/job_server/__init__.py
diff --git a/openpype/modules/default_modules/job_queue/job_server/job_queue_route.py b/openpype/modules/job_queue/job_server/job_queue_route.py
similarity index 100%
rename from openpype/modules/default_modules/job_queue/job_server/job_queue_route.py
rename to openpype/modules/job_queue/job_server/job_queue_route.py
diff --git a/openpype/modules/default_modules/job_queue/job_server/jobs.py b/openpype/modules/job_queue/job_server/jobs.py
similarity index 100%
rename from openpype/modules/default_modules/job_queue/job_server/jobs.py
rename to openpype/modules/job_queue/job_server/jobs.py
diff --git a/openpype/modules/default_modules/job_queue/job_server/server.py b/openpype/modules/job_queue/job_server/server.py
similarity index 100%
rename from openpype/modules/default_modules/job_queue/job_server/server.py
rename to openpype/modules/job_queue/job_server/server.py
diff --git a/openpype/modules/default_modules/job_queue/job_server/utils.py b/openpype/modules/job_queue/job_server/utils.py
similarity index 100%
rename from openpype/modules/default_modules/job_queue/job_server/utils.py
rename to openpype/modules/job_queue/job_server/utils.py
diff --git a/openpype/modules/default_modules/job_queue/job_server/workers.py b/openpype/modules/job_queue/job_server/workers.py
similarity index 100%
rename from openpype/modules/default_modules/job_queue/job_server/workers.py
rename to openpype/modules/job_queue/job_server/workers.py
diff --git a/openpype/modules/default_modules/job_queue/job_server/workers_rpc_route.py b/openpype/modules/job_queue/job_server/workers_rpc_route.py
similarity index 100%
rename from openpype/modules/default_modules/job_queue/job_server/workers_rpc_route.py
rename to openpype/modules/job_queue/job_server/workers_rpc_route.py
diff --git a/openpype/modules/default_modules/job_queue/job_workers/__init__.py b/openpype/modules/job_queue/job_workers/__init__.py
similarity index 100%
rename from openpype/modules/default_modules/job_queue/job_workers/__init__.py
rename to openpype/modules/job_queue/job_workers/__init__.py
diff --git a/openpype/modules/default_modules/job_queue/job_workers/base_worker.py b/openpype/modules/job_queue/job_workers/base_worker.py
similarity index 100%
rename from openpype/modules/default_modules/job_queue/job_workers/base_worker.py
rename to openpype/modules/job_queue/job_workers/base_worker.py
diff --git a/openpype/modules/default_modules/job_queue/module.py b/openpype/modules/job_queue/module.py
similarity index 97%
rename from openpype/modules/default_modules/job_queue/module.py
rename to openpype/modules/job_queue/module.py
index 719d7c8f38..f1d7251e85 100644
--- a/openpype/modules/default_modules/job_queue/module.py
+++ b/openpype/modules/job_queue/module.py
@@ -50,11 +50,12 @@ class JobQueueModule(OpenPypeModule):
name = "job_queue"
def initialize(self, modules_settings):
- server_url = modules_settings.get("server_url") or ""
+ module_settings = modules_settings.get(self.name) or {}
+ server_url = module_settings.get("server_url") or ""
self._server_url = self.url_conversion(server_url)
jobs_root_mapping = self._roots_mapping_conversion(
- modules_settings.get("jobs_root")
+ module_settings.get("jobs_root")
)
self._jobs_root_mapping = jobs_root_mapping
diff --git a/openpype/modules/default_modules/log_viewer/__init__.py b/openpype/modules/log_viewer/__init__.py
similarity index 100%
rename from openpype/modules/default_modules/log_viewer/__init__.py
rename to openpype/modules/log_viewer/__init__.py
diff --git a/openpype/modules/default_modules/log_viewer/log_view_module.py b/openpype/modules/log_viewer/log_view_module.py
similarity index 100%
rename from openpype/modules/default_modules/log_viewer/log_view_module.py
rename to openpype/modules/log_viewer/log_view_module.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/.nojekyll b/openpype/modules/log_viewer/tray/__init__.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/.nojekyll
rename to openpype/modules/log_viewer/tray/__init__.py
diff --git a/openpype/modules/default_modules/log_viewer/tray/app.py b/openpype/modules/log_viewer/tray/app.py
similarity index 100%
rename from openpype/modules/default_modules/log_viewer/tray/app.py
rename to openpype/modules/log_viewer/tray/app.py
diff --git a/openpype/modules/default_modules/log_viewer/tray/models.py b/openpype/modules/log_viewer/tray/models.py
similarity index 100%
rename from openpype/modules/default_modules/log_viewer/tray/models.py
rename to openpype/modules/log_viewer/tray/models.py
diff --git a/openpype/modules/default_modules/log_viewer/tray/widgets.py b/openpype/modules/log_viewer/tray/widgets.py
similarity index 100%
rename from openpype/modules/default_modules/log_viewer/tray/widgets.py
rename to openpype/modules/log_viewer/tray/widgets.py
diff --git a/openpype/modules/default_modules/muster/__init__.py b/openpype/modules/muster/__init__.py
similarity index 100%
rename from openpype/modules/default_modules/muster/__init__.py
rename to openpype/modules/muster/__init__.py
diff --git a/openpype/modules/default_modules/muster/muster.py b/openpype/modules/muster/muster.py
similarity index 100%
rename from openpype/modules/default_modules/muster/muster.py
rename to openpype/modules/muster/muster.py
diff --git a/openpype/modules/default_modules/muster/rest_api.py b/openpype/modules/muster/rest_api.py
similarity index 100%
rename from openpype/modules/default_modules/muster/rest_api.py
rename to openpype/modules/muster/rest_api.py
diff --git a/openpype/modules/default_modules/muster/widget_login.py b/openpype/modules/muster/widget_login.py
similarity index 100%
rename from openpype/modules/default_modules/muster/widget_login.py
rename to openpype/modules/muster/widget_login.py
diff --git a/openpype/modules/default_modules/python_console_interpreter/__init__.py b/openpype/modules/python_console_interpreter/__init__.py
similarity index 100%
rename from openpype/modules/default_modules/python_console_interpreter/__init__.py
rename to openpype/modules/python_console_interpreter/__init__.py
diff --git a/openpype/modules/default_modules/python_console_interpreter/module.py b/openpype/modules/python_console_interpreter/module.py
similarity index 100%
rename from openpype/modules/default_modules/python_console_interpreter/module.py
rename to openpype/modules/python_console_interpreter/module.py
diff --git a/openpype/modules/default_modules/python_console_interpreter/window/__init__.py b/openpype/modules/python_console_interpreter/window/__init__.py
similarity index 100%
rename from openpype/modules/default_modules/python_console_interpreter/window/__init__.py
rename to openpype/modules/python_console_interpreter/window/__init__.py
diff --git a/openpype/modules/default_modules/python_console_interpreter/window/widgets.py b/openpype/modules/python_console_interpreter/window/widgets.py
similarity index 100%
rename from openpype/modules/default_modules/python_console_interpreter/window/widgets.py
rename to openpype/modules/python_console_interpreter/window/widgets.py
diff --git a/openpype/modules/default_modules/slack/README.md b/openpype/modules/slack/README.md
similarity index 100%
rename from openpype/modules/default_modules/slack/README.md
rename to openpype/modules/slack/README.md
diff --git a/openpype/modules/default_modules/slack/__init__.py b/openpype/modules/slack/__init__.py
similarity index 100%
rename from openpype/modules/default_modules/slack/__init__.py
rename to openpype/modules/slack/__init__.py
diff --git a/openpype/modules/default_modules/slack/launch_hooks/pre_python2_vendor.py b/openpype/modules/slack/launch_hooks/pre_python2_vendor.py
similarity index 100%
rename from openpype/modules/default_modules/slack/launch_hooks/pre_python2_vendor.py
rename to openpype/modules/slack/launch_hooks/pre_python2_vendor.py
diff --git a/openpype/modules/default_modules/slack/manifest.yml b/openpype/modules/slack/manifest.yml
similarity index 100%
rename from openpype/modules/default_modules/slack/manifest.yml
rename to openpype/modules/slack/manifest.yml
diff --git a/openpype/modules/default_modules/slack/plugins/publish/collect_slack_family.py b/openpype/modules/slack/plugins/publish/collect_slack_family.py
similarity index 100%
rename from openpype/modules/default_modules/slack/plugins/publish/collect_slack_family.py
rename to openpype/modules/slack/plugins/publish/collect_slack_family.py
diff --git a/openpype/modules/default_modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py
similarity index 100%
rename from openpype/modules/default_modules/slack/plugins/publish/integrate_slack_api.py
rename to openpype/modules/slack/plugins/publish/integrate_slack_api.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.appveyor.yml b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/.appveyor.yml
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.appveyor.yml
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/.appveyor.yml
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.coveragerc b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/.coveragerc
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.coveragerc
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/.coveragerc
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.flake8 b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/.flake8
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.flake8
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/.flake8
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.github/contributing.md b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/.github/contributing.md
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.github/contributing.md
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/.github/contributing.md
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.github/issue_template.md b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/.github/issue_template.md
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.github/issue_template.md
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/.github/issue_template.md
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.github/maintainers_guide.md b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/.github/maintainers_guide.md
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.github/maintainers_guide.md
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/.github/maintainers_guide.md
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.github/pull_request_template.md b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/.github/pull_request_template.md
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.github/pull_request_template.md
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/.github/pull_request_template.md
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.gitignore b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/.gitignore
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.gitignore
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/.gitignore
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.travis.yml b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/.travis.yml
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/.travis.yml
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/.travis.yml
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/LICENSE b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/LICENSE
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/LICENSE
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/LICENSE
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/MANIFEST.in b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/MANIFEST.in
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/MANIFEST.in
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/MANIFEST.in
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/README.rst b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/README.rst
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/README.rst
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/README.rst
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/.gitignore b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/.gitignore
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/.gitignore
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/.gitignore
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/Makefile b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/Makefile
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/Makefile
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/Makefile
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/conf.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/conf.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/conf.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/conf.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/layout.html b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/layout.html
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/layout.html
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/layout.html
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/localtoc.html b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/localtoc.html
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/localtoc.html
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/localtoc.html
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/relations.html b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/relations.html
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/relations.html
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/relations.html
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/sidebar.html b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/sidebar.html
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/sidebar.html
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/sidebar.html
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/static/default.css_t b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/static/default.css_t
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/static/default.css_t
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/static/default.css_t
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/static/docs.css_t b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/static/docs.css_t
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/static/docs.css_t
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/static/docs.css_t
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/static/pygments.css_t b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/static/pygments.css_t
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/static/pygments.css_t
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/static/pygments.css_t
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/theme.conf b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/theme.conf
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/theme.conf
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/_themes/slack/theme.conf
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/about.rst b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/about.rst
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/about.rst
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/about.rst
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/auth.rst b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/auth.rst
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/auth.rst
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/auth.rst
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/basic_usage.rst b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/basic_usage.rst
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/basic_usage.rst
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/basic_usage.rst
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/changelog.rst b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/changelog.rst
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/changelog.rst
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/changelog.rst
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/conf.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/conf.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/conf.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/conf.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/conversations.rst b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/conversations.rst
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/conversations.rst
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/conversations.rst
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/faq.rst b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/faq.rst
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/faq.rst
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/faq.rst
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/index.rst b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/index.rst
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/index.rst
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/index.rst
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/make.bat b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/make.bat
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/make.bat
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/make.bat
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/metadata.rst b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/metadata.rst
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/metadata.rst
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/metadata.rst
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/real_time_messaging.rst b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/real_time_messaging.rst
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs-src/real_time_messaging.rst
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs-src/real_time_messaging.rst
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs.sh b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs.sh
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs.sh
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs.sh
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/.buildinfo b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/.buildinfo
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/.buildinfo
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/.buildinfo
diff --git a/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/.nojekyll b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/.nojekyll
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/ajax-loader.gif b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/ajax-loader.gif
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/ajax-loader.gif
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/ajax-loader.gif
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/basic.css b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/basic.css
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/basic.css
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/basic.css
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/classic.css b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/classic.css
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/classic.css
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/classic.css
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/comment-bright.png b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/comment-bright.png
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/comment-bright.png
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/comment-bright.png
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/comment-close.png b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/comment-close.png
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/comment-close.png
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/comment-close.png
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/comment.png b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/comment.png
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/comment.png
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/comment.png
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/default.css b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/default.css
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/default.css
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/default.css
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/docs.css b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/docs.css
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/docs.css
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/docs.css
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/doctools.js b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/doctools.js
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/doctools.js
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/doctools.js
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/documentation_options.js b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/documentation_options.js
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/documentation_options.js
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/documentation_options.js
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/down-pressed.png b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/down-pressed.png
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/down-pressed.png
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/down-pressed.png
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/down.png b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/down.png
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/down.png
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/down.png
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/file.png b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/file.png
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/file.png
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/file.png
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/jquery-3.2.1.js b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/jquery-3.2.1.js
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/jquery-3.2.1.js
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/jquery-3.2.1.js
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/jquery.js b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/jquery.js
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/jquery.js
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/jquery.js
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/language_data.js b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/language_data.js
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/language_data.js
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/language_data.js
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/minus.png b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/minus.png
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/minus.png
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/minus.png
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/plus.png b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/plus.png
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/plus.png
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/plus.png
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/pygments.css b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/pygments.css
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/pygments.css
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/pygments.css
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/searchtools.js b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/searchtools.js
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/searchtools.js
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/searchtools.js
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/sidebar.js b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/sidebar.js
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/sidebar.js
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/sidebar.js
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/underscore-1.3.1.js b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/underscore-1.3.1.js
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/underscore-1.3.1.js
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/underscore-1.3.1.js
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/underscore.js b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/underscore.js
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/underscore.js
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/underscore.js
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/up-pressed.png b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/up-pressed.png
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/up-pressed.png
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/up-pressed.png
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/up.png b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/up.png
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/up.png
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/up.png
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/websupport.js b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/websupport.js
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/websupport.js
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/_static/websupport.js
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/about.html b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/about.html
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/about.html
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/about.html
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/auth.html b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/auth.html
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/auth.html
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/auth.html
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/basic_usage.html b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/basic_usage.html
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/basic_usage.html
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/basic_usage.html
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/changelog.html b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/changelog.html
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/changelog.html
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/changelog.html
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/conversations.html b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/conversations.html
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/conversations.html
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/conversations.html
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/faq.html b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/faq.html
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/faq.html
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/faq.html
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/genindex.html b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/genindex.html
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/genindex.html
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/genindex.html
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/index.html b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/index.html
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/index.html
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/index.html
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/metadata.html b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/metadata.html
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/metadata.html
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/metadata.html
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/objects.inv b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/objects.inv
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/objects.inv
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/objects.inv
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/real_time_messaging.html b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/real_time_messaging.html
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/real_time_messaging.html
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/real_time_messaging.html
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/search.html b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/search.html
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/search.html
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/search.html
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/searchindex.js b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/searchindex.js
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/docs/searchindex.js
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/docs/searchindex.js
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/requirements.txt b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/requirements.txt
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/requirements.txt
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/requirements.txt
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/setup.cfg b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/setup.cfg
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/setup.cfg
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/setup.cfg
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/setup.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/setup.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/setup.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/setup.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/__init__.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/__init__.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/__init__.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/__init__.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/channel.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/channel.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/channel.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/channel.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/client.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/client.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/client.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/client.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/exceptions.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/exceptions.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/exceptions.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/exceptions.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/im.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/im.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/im.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/im.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/server.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/server.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/server.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/server.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/slackrequest.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/slackrequest.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/slackrequest.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/slackrequest.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/user.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/user.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/user.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/user.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/util.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/util.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/util.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/util.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/version.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/version.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/slackclient/version.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/slackclient/version.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/test_requirements.txt b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/test_requirements.txt
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/test_requirements.txt
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/test_requirements.txt
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/conftest.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/conftest.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/conftest.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/conftest.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/data/channel.created.json b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/data/channel.created.json
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/data/channel.created.json
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/data/channel.created.json
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/data/im.created.json b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/data/im.created.json
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/data/im.created.json
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/data/im.created.json
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/data/rtm.start.json b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/data/rtm.start.json
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/data/rtm.start.json
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/data/rtm.start.json
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/data/slack_logo.png b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/data/slack_logo.png
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/data/slack_logo.png
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/data/slack_logo.png
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/test_channel.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/test_channel.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/test_channel.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/test_channel.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/test_server.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/test_server.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/test_server.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/test_server.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/test_slackclient.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/test_slackclient.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/test_slackclient.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/test_slackclient.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/test_slackrequest.py b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/test_slackrequest.py
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tests/test_slackrequest.py
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/tests/test_slackrequest.py
diff --git a/openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tox.ini b/openpype/modules/slack/python2_vendor/python-slack-sdk-1/tox.ini
similarity index 100%
rename from openpype/modules/default_modules/slack/python2_vendor/python-slack-sdk-1/tox.ini
rename to openpype/modules/slack/python2_vendor/python-slack-sdk-1/tox.ini
diff --git a/openpype/modules/default_modules/slack/slack_module.py b/openpype/modules/slack/slack_module.py
similarity index 100%
rename from openpype/modules/default_modules/slack/slack_module.py
rename to openpype/modules/slack/slack_module.py
diff --git a/openpype/modules/default_modules/webserver/__init__.py b/openpype/modules/webserver/__init__.py
similarity index 100%
rename from openpype/modules/default_modules/webserver/__init__.py
rename to openpype/modules/webserver/__init__.py
diff --git a/openpype/modules/default_modules/webserver/base_routes.py b/openpype/modules/webserver/base_routes.py
similarity index 100%
rename from openpype/modules/default_modules/webserver/base_routes.py
rename to openpype/modules/webserver/base_routes.py
diff --git a/openpype/modules/default_modules/webserver/host_console_listener.py b/openpype/modules/webserver/host_console_listener.py
similarity index 100%
rename from openpype/modules/default_modules/webserver/host_console_listener.py
rename to openpype/modules/webserver/host_console_listener.py
diff --git a/openpype/modules/default_modules/webserver/server.py b/openpype/modules/webserver/server.py
similarity index 100%
rename from openpype/modules/default_modules/webserver/server.py
rename to openpype/modules/webserver/server.py
diff --git a/openpype/modules/default_modules/webserver/webserver_module.py b/openpype/modules/webserver/webserver_module.py
similarity index 100%
rename from openpype/modules/default_modules/webserver/webserver_module.py
rename to openpype/modules/webserver/webserver_module.py
diff --git a/openpype/plugins/publish/cleanup.py b/openpype/plugins/publish/cleanup.py
index b8104078d9..f29e6ccd4e 100644
--- a/openpype/plugins/publish/cleanup.py
+++ b/openpype/plugins/publish/cleanup.py
@@ -15,6 +15,25 @@ class CleanUp(pyblish.api.InstancePlugin):
order = pyblish.api.IntegratorOrder + 10
label = "Clean Up"
+ hosts = [
+ "aftereffects",
+ "blender",
+ "celaction",
+ "flame",
+ "fusion",
+ "harmony",
+ "hiero",
+ "houdini",
+ "maya",
+ "nuke",
+ "photoshop",
+ "resolve",
+ "tvpaint",
+ "unreal",
+ "standalonepublisher",
+ "webpublisher",
+ "shell"
+ ]
exclude_families = ["clip"]
optional = True
active = True
diff --git a/openpype/plugins/publish/cleanup_explicit.py b/openpype/plugins/publish/cleanup_explicit.py
new file mode 100644
index 0000000000..88bba34532
--- /dev/null
+++ b/openpype/plugins/publish/cleanup_explicit.py
@@ -0,0 +1,152 @@
+# -*- coding: utf-8 -*-
+"""Cleanup files when publishing is done."""
+import os
+import shutil
+import pyblish.api
+
+
+class ExplicitCleanUp(pyblish.api.ContextPlugin):
+ """Cleans up the files and folder defined to be deleted.
+
+ plugin is looking for 2 keys into context data:
+ - `cleanupFullPaths` - full paths that should be removed not matter if
+ is path to file or to directory
+ - `cleanupEmptyDirs` - full paths to directories that should be removed
+ only if do not contain any file in it but will be removed if contain
+ sub-folders
+ """
+
+ order = pyblish.api.IntegratorOrder + 10
+ label = "Explicit Clean Up"
+ optional = True
+ active = True
+
+ def process(self, context):
+ cleanup_full_paths = context.data.get("cleanupFullPaths")
+ cleanup_empty_dirs = context.data.get("cleanupEmptyDirs")
+
+ self._remove_full_paths(cleanup_full_paths)
+ self._remove_empty_dirs(cleanup_empty_dirs)
+
+ def _remove_full_paths(self, full_paths):
+ """Remove files and folders from disc.
+
+ Folders are removed with whole content.
+ """
+ if not full_paths:
+ self.log.debug("No full paths to cleanup were collected.")
+ return
+
+ # Separate paths into files and directories
+ filepaths = set()
+ dirpaths = set()
+ for path in full_paths:
+ # Skip empty items
+ if not path:
+ continue
+ # Normalize path
+ normalized = os.path.normpath(path)
+ # Check if path exists
+ if not os.path.exists(normalized):
+ continue
+
+ if os.path.isfile(normalized):
+ filepaths.add(normalized)
+ else:
+ dirpaths.add(normalized)
+
+ # Store failed paths with exception
+ failed = []
+ # Store removed filepaths for logging
+ succeded_files = set()
+ # Remove file by file
+ for filepath in filepaths:
+ try:
+ os.remove(filepath)
+ succeded_files.add(filepath)
+ except Exception as exc:
+ failed.append((filepath, exc))
+
+ if succeded_files:
+ self.log.info(
+ "Removed files:\n{}".format("\n".join(succeded_files))
+ )
+
+ # Delete folders with it's content
+ succeded_dirs = set()
+ for dirpath in dirpaths:
+ # Check if directory still exists
+ # - it is possible that directory was already deleted with
+ # different dirpath to delete
+ if os.path.exists(dirpath):
+ try:
+ shutil.rmtree(dirpath)
+ succeded_dirs.add(dirpath)
+ except Exception:
+ failed.append(dirpath)
+
+ if succeded_dirs:
+ self.log.info(
+ "Removed direcoties:\n{}".format("\n".join(succeded_dirs))
+ )
+
+ # Prepare lines for report of failed removements
+ lines = []
+ for filepath, exc in failed:
+ lines.append("{}: {}".format(filepath, str(exc)))
+
+ if lines:
+ self.log.warning(
+ "Failed to remove filepaths:\n{}".format("\n".join(lines))
+ )
+
+ def _remove_empty_dirs(self, empty_dirpaths):
+ """Remove directories if do not contain any files."""
+ if not empty_dirpaths:
+ self.log.debug("No empty dirs to cleanup were collected.")
+ return
+
+ # First filtering of directories and making sure those are
+ # existing directories
+ filtered_dirpaths = set()
+ for path in empty_dirpaths:
+ if (
+ path
+ and os.path.exists(path)
+ and os.path.isdir(path)
+ ):
+ filtered_dirpaths.add(os.path.normpath(path))
+
+ to_delete_dirpaths = set()
+ to_skip_dirpaths = set()
+ # Check if contain any files (or it's subfolders contain files)
+ for dirpath in filtered_dirpaths:
+ valid = True
+ for _, _, filenames in os.walk(dirpath):
+ if filenames:
+ valid = False
+ break
+
+ if valid:
+ to_delete_dirpaths.add(dirpath)
+ else:
+ to_skip_dirpaths.add(dirpath)
+
+ if to_skip_dirpaths:
+ self.log.debug(
+ "Skipped directories because contain files:\n{}".format(
+ "\n".join(to_skip_dirpaths)
+ )
+ )
+
+ # Remove empty directies
+ for dirpath in to_delete_dirpaths:
+ if os.path.exists(dirpath):
+ shutil.rmtree(dirpath)
+
+ if to_delete_dirpaths:
+ self.log.debug(
+ "Deleted empty directories:\n{}".format(
+ "\n".join(to_delete_dirpaths)
+ )
+ )
diff --git a/openpype/plugins/publish/collect_anatomy_context_data.py b/openpype/plugins/publish/collect_anatomy_context_data.py
index 6b95979b76..07de1b4420 100644
--- a/openpype/plugins/publish/collect_anatomy_context_data.py
+++ b/openpype/plugins/publish/collect_anatomy_context_data.py
@@ -49,24 +49,27 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin):
project_entity = context.data["projectEntity"]
asset_entity = context.data["assetEntity"]
- hierarchy_items = asset_entity["data"]["parents"]
- hierarchy = ""
- if hierarchy_items:
- hierarchy = os.path.join(*hierarchy_items)
-
asset_tasks = asset_entity["data"]["tasks"]
task_type = asset_tasks.get(task_name, {}).get("type")
project_task_types = project_entity["config"]["tasks"]
task_code = project_task_types.get(task_type, {}).get("short_name")
+ asset_parents = asset_entity["data"]["parents"]
+ hierarchy = "/".join(asset_parents)
+
+ parent_name = project_entity["name"]
+ if asset_parents:
+ parent_name = asset_parents[-1]
+
context_data = {
"project": {
"name": project_entity["name"],
"code": project_entity["data"].get("code")
},
"asset": asset_entity["name"],
- "hierarchy": hierarchy.replace("\\", "/"),
+ "parent": parent_name,
+ "hierarchy": hierarchy,
"task": {
"name": task_name,
"type": task_type,
diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py
index da6a2195ee..74b556e28a 100644
--- a/openpype/plugins/publish/collect_anatomy_instance_data.py
+++ b/openpype/plugins/publish/collect_anatomy_instance_data.py
@@ -242,7 +242,11 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
asset_doc = instance.data.get("assetEntity")
if asset_doc and asset_doc["_id"] != context_asset_doc["_id"]:
parents = asset_doc["data"].get("parents") or list()
+ parent_name = project_doc["name"]
+ if parents:
+ parent_name = parents[-1]
anatomy_updates["hierarchy"] = "/".join(parents)
+ anatomy_updates["parent"] = parent_name
# Task
task_name = instance.data.get("task")
diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py
index dd670ff850..571d0d56a4 100644
--- a/openpype/plugins/publish/collect_otio_subset_resources.py
+++ b/openpype/plugins/publish/collect_otio_subset_resources.py
@@ -171,8 +171,7 @@ class CollectOcioSubsetResources(pyblish.api.InstancePlugin):
instance.data["representations"].append(repre)
self.log.debug(">>>>>>>> {}".format(repre))
- import pprint
- self.log.debug(pprint.pformat(instance.data))
+ self.log.debug(instance.data)
def _create_representation(self, start, end, **kwargs):
"""
diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py
index 35d9e4b2f2..df7dc47e17 100644
--- a/openpype/plugins/publish/extract_burnin.py
+++ b/openpype/plugins/publish/extract_burnin.py
@@ -14,9 +14,11 @@ import openpype
import openpype.api
from openpype.lib import (
get_pype_execute_args,
- should_decompress,
- get_decompress_dir,
- decompress,
+
+ get_transcode_temp_directory,
+ convert_for_ffmpeg,
+ should_convert_for_ffmpeg,
+
CREATE_NO_WINDOW
)
@@ -70,18 +72,6 @@ class ExtractBurnin(openpype.api.Extractor):
options = None
def process(self, instance):
- # ffmpeg doesn't support multipart exrs
- if instance.data.get("multipartExr") is True:
- instance_label = (
- getattr(instance, "label", None)
- or instance.data.get("label")
- or instance.data.get("name")
- )
- self.log.info((
- "Instance \"{}\" contain \"multipartExr\". Skipped."
- ).format(instance_label))
- return
-
# QUESTION what is this for and should we raise an exception?
if "representations" not in instance.data:
raise RuntimeError("Burnin needs already created mov to work on.")
@@ -95,6 +85,55 @@ class ExtractBurnin(openpype.api.Extractor):
self.log.debug("Removing representation: {}".format(repre))
instance.data["representations"].remove(repre)
+ def _get_burnins_per_representations(self, instance, src_burnin_defs):
+ self.log.debug("Filtering of representations and their burnins starts")
+
+ filtered_repres = []
+ repres = instance.data.get("representations") or []
+ for idx, repre in enumerate(repres):
+ self.log.debug("repre ({}): `{}`".format(idx + 1, repre["name"]))
+ if not self.repres_is_valid(repre):
+ continue
+
+ repre_burnin_links = repre.get("burnins", [])
+ self.log.debug(
+ "repre_burnin_links: {}".format(repre_burnin_links)
+ )
+
+ burnin_defs = copy.deepcopy(src_burnin_defs)
+ self.log.debug(
+ "burnin_defs.keys(): {}".format(burnin_defs.keys())
+ )
+
+ # Filter output definition by `burnin` represetation key
+ repre_linked_burnins = {
+ name: output
+ for name, output in burnin_defs.items()
+ if name in repre_burnin_links
+ }
+ self.log.debug(
+ "repre_linked_burnins: {}".format(repre_linked_burnins)
+ )
+
+ # if any match then replace burnin defs and follow tag filtering
+ if repre_linked_burnins:
+ burnin_defs = repre_linked_burnins
+
+ # Filter output definition by representation tags (optional)
+ repre_burnin_defs = self.filter_burnins_by_tags(
+ burnin_defs, repre["tags"]
+ )
+ if not repre_burnin_defs:
+ self.log.info((
+ "Skipped representation. All burnin definitions from"
+ " selected profile does not match to representation's"
+ " tags. \"{}\""
+ ).format(str(repre["tags"])))
+ continue
+ filtered_repres.append((repre, repre_burnin_defs))
+
+ return filtered_repres
+
def main_process(self, instance):
# TODO get these data from context
host_name = instance.context.data["hostName"]
@@ -110,8 +149,7 @@ class ExtractBurnin(openpype.api.Extractor):
).format(host_name, family, task_name))
return
- self.log.debug("profile: {}".format(
- profile))
+ self.log.debug("profile: {}".format(profile))
# Pre-filter burnin definitions by instance families
burnin_defs = self.filter_burnins_defs(profile, instance)
@@ -133,46 +171,10 @@ class ExtractBurnin(openpype.api.Extractor):
# Executable args that will execute the script
# [pype executable, *pype script, "run"]
executable_args = get_pype_execute_args("run", scriptpath)
-
- for idx, repre in enumerate(tuple(instance.data["representations"])):
- self.log.debug("repre ({}): `{}`".format(idx + 1, repre["name"]))
-
- repre_burnin_links = repre.get("burnins", [])
-
- if not self.repres_is_valid(repre):
- continue
-
- self.log.debug("repre_burnin_links: {}".format(
- repre_burnin_links))
-
- self.log.debug("burnin_defs.keys(): {}".format(
- burnin_defs.keys()))
-
- # Filter output definition by `burnin` represetation key
- repre_linked_burnins = {
- name: output for name, output in burnin_defs.items()
- if name in repre_burnin_links
- }
- self.log.debug("repre_linked_burnins: {}".format(
- repre_linked_burnins))
-
- # if any match then replace burnin defs and follow tag filtering
- _burnin_defs = copy.deepcopy(burnin_defs)
- if repre_linked_burnins:
- _burnin_defs = repre_linked_burnins
-
- # Filter output definition by representation tags (optional)
- repre_burnin_defs = self.filter_burnins_by_tags(
- _burnin_defs, repre["tags"]
- )
- if not repre_burnin_defs:
- self.log.info((
- "Skipped representation. All burnin definitions from"
- " selected profile does not match to representation's"
- " tags. \"{}\""
- ).format(str(repre["tags"])))
- continue
-
+ burnins_per_repres = self._get_burnins_per_representations(
+ instance, burnin_defs
+ )
+ for repre, repre_burnin_defs in burnins_per_repres:
# Create copy of `_burnin_data` and `_temp_data` for repre.
burnin_data = copy.deepcopy(_burnin_data)
temp_data = copy.deepcopy(_temp_data)
@@ -180,6 +182,41 @@ class ExtractBurnin(openpype.api.Extractor):
# Prepare representation based data.
self.prepare_repre_data(instance, repre, burnin_data, temp_data)
+ src_repre_staging_dir = repre["stagingDir"]
+ # Should convert representation source files before processing?
+ repre_files = repre["files"]
+ if isinstance(repre_files, (tuple, list)):
+ filename = repre_files[0]
+ else:
+ filename = repre_files
+
+ first_input_path = os.path.join(src_repre_staging_dir, filename)
+ # Determine if representation requires pre conversion for ffmpeg
+ do_convert = should_convert_for_ffmpeg(first_input_path)
+ # If result is None the requirement of conversion can't be
+ # determined
+ if do_convert is None:
+ self.log.info((
+ "Can't determine if representation requires conversion."
+ " Skipped."
+ ))
+ continue
+
+ # Do conversion if needed
+ # - change staging dir of source representation
+ # - must be set back after output definitions processing
+ if do_convert:
+ new_staging_dir = get_transcode_temp_directory()
+ repre["stagingDir"] = new_staging_dir
+
+ convert_for_ffmpeg(
+ first_input_path,
+ new_staging_dir,
+ _temp_data["frameStart"],
+ _temp_data["frameEnd"],
+ self.log
+ )
+
# Add anatomy keys to burnin_data.
filled_anatomy = anatomy.format_all(burnin_data)
burnin_data["anatomy"] = filled_anatomy.get_solved()
@@ -199,6 +236,7 @@ class ExtractBurnin(openpype.api.Extractor):
files_to_delete = []
for filename_suffix, burnin_def in repre_burnin_defs.items():
new_repre = copy.deepcopy(repre)
+ new_repre["stagingDir"] = src_repre_staging_dir
# Keep "ftrackreview" tag only on first output
if first_output:
@@ -229,27 +267,9 @@ class ExtractBurnin(openpype.api.Extractor):
new_repre["outputName"] = new_name
# Prepare paths and files for process.
- self.input_output_paths(new_repre, temp_data, filename_suffix)
-
- decompressed_dir = ''
- full_input_path = temp_data["full_input_path"]
- do_decompress = should_decompress(full_input_path)
- if do_decompress:
- decompressed_dir = get_decompress_dir()
-
- decompress(
- decompressed_dir,
- full_input_path,
- temp_data["frame_start"],
- temp_data["frame_end"],
- self.log
- )
-
- # input path changed, 'decompressed' added
- input_file = os.path.basename(full_input_path)
- temp_data["full_input_path"] = os.path.join(
- decompressed_dir,
- input_file)
+ self.input_output_paths(
+ repre, new_repre, temp_data, filename_suffix
+ )
# Data for burnin script
script_data = {
@@ -305,6 +325,14 @@ class ExtractBurnin(openpype.api.Extractor):
# Add new representation to instance
instance.data["representations"].append(new_repre)
+ # Cleanup temp staging dir after procesisng of output definitions
+ if do_convert:
+ temp_dir = repre["stagingDir"]
+ shutil.rmtree(temp_dir)
+ # Set staging dir of source representation back to previous
+ # value
+ repre["stagingDir"] = src_repre_staging_dir
+
# Remove source representation
# NOTE we maybe can keep source representation if necessary
instance.data["representations"].remove(repre)
@@ -317,9 +345,6 @@ class ExtractBurnin(openpype.api.Extractor):
os.remove(filepath)
self.log.debug("Removed: \"{}\"".format(filepath))
- if do_decompress and os.path.exists(decompressed_dir):
- shutil.rmtree(decompressed_dir)
-
def _get_burnin_options(self):
# Prepare burnin options
burnin_options = copy.deepcopy(self.default_options)
@@ -474,6 +499,12 @@ class ExtractBurnin(openpype.api.Extractor):
"Representation \"{}\" don't have \"burnin\" tag. Skipped."
).format(repre["name"]))
return False
+
+ if not repre.get("files"):
+ self.log.warning((
+ "Representation \"{}\" have empty files. Skipped."
+ ).format(repre["name"]))
+ return False
return True
def filter_burnins_by_tags(self, burnin_defs, tags):
@@ -504,7 +535,9 @@ class ExtractBurnin(openpype.api.Extractor):
return filtered_burnins
- def input_output_paths(self, new_repre, temp_data, filename_suffix):
+ def input_output_paths(
+ self, src_repre, new_repre, temp_data, filename_suffix
+ ):
"""Prepare input and output paths for representation.
Store data to `temp_data` for keys "full_input_path" which is full path
@@ -565,12 +598,13 @@ class ExtractBurnin(openpype.api.Extractor):
repre_files = output_filename
- stagingdir = new_repre["stagingDir"]
+ src_stagingdir = src_repre["stagingDir"]
+ dst_stagingdir = new_repre["stagingDir"]
full_input_path = os.path.join(
- os.path.normpath(stagingdir), input_filename
+ os.path.normpath(src_stagingdir), input_filename
).replace("\\", "/")
full_output_path = os.path.join(
- os.path.normpath(stagingdir), output_filename
+ os.path.normpath(dst_stagingdir), output_filename
).replace("\\", "/")
temp_data["full_input_path"] = full_input_path
@@ -587,7 +621,7 @@ class ExtractBurnin(openpype.api.Extractor):
if is_sequence:
for filename in input_filenames:
filepath = os.path.join(
- os.path.normpath(stagingdir), filename
+ os.path.normpath(src_stagingdir), filename
).replace("\\", "/")
full_input_paths.append(filepath)
diff --git a/openpype/plugins/publish/extract_jpeg_exr.py b/openpype/plugins/publish/extract_jpeg_exr.py
index 3c08c1862d..3cb4f8f9cb 100644
--- a/openpype/plugins/publish/extract_jpeg_exr.py
+++ b/openpype/plugins/publish/extract_jpeg_exr.py
@@ -7,10 +7,11 @@ from openpype.lib import (
run_subprocess,
path_to_subprocess_arg,
- should_decompress,
- get_decompress_dir,
- decompress
+ get_transcode_temp_directory,
+ convert_for_ffmpeg,
+ should_convert_for_ffmpeg
)
+
import shutil
@@ -31,57 +32,56 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin):
def process(self, instance):
self.log.info("subset {}".format(instance.data['subset']))
+
+ # skip crypto passes.
if 'crypto' in instance.data['subset']:
+ self.log.info("Skipping crypto passes.")
return
- do_decompress = False
- # ffmpeg doesn't support multipart exrs, use oiiotool if available
- if instance.data.get("multipartExr") is True:
- return
-
- # Skip review when requested.
+ # Skip if review not set.
if not instance.data.get("review", True):
+ self.log.info("Skipping - no review set on instance.")
return
- # get representation and loop them
- representations = instance.data["representations"]
-
- # filter out mov and img sequences
- representations_new = representations[:]
-
- for repre in representations:
- tags = repre.get("tags", [])
- self.log.debug(repre)
- valid = 'review' in tags or "thumb-nuke" in tags
- if not valid:
- continue
-
- if not isinstance(repre['files'], (list, tuple)):
- input_file = repre['files']
+ filtered_repres = self._get_filtered_repres(instance)
+ for repre in filtered_repres:
+ repre_files = repre["files"]
+ if not isinstance(repre_files, (list, tuple)):
+ input_file = repre_files
else:
- file_index = int(float(len(repre['files'])) * 0.5)
- input_file = repre['files'][file_index]
+ file_index = int(float(len(repre_files)) * 0.5)
+ input_file = repre_files[file_index]
- stagingdir = os.path.normpath(repre.get("stagingDir"))
+ stagingdir = os.path.normpath(repre["stagingDir"])
- # input_file = (
- # collections[0].format('{head}{padding}{tail}') % start
- # )
full_input_path = os.path.join(stagingdir, input_file)
self.log.info("input {}".format(full_input_path))
- decompressed_dir = ''
- do_decompress = should_decompress(full_input_path)
- if do_decompress:
- decompressed_dir = get_decompress_dir()
+ do_convert = should_convert_for_ffmpeg(full_input_path)
+ # If result is None the requirement of conversion can't be
+ # determined
+ if do_convert is None:
+ self.log.info((
+ "Can't determine if representation requires conversion."
+ " Skipped."
+ ))
+ continue
- decompress(
- decompressed_dir,
- full_input_path)
- # input path changed, 'decompressed' added
- full_input_path = os.path.join(
- decompressed_dir,
- input_file)
+ # Do conversion if needed
+ # - change staging dir of source representation
+ # - must be set back after output definitions processing
+ convert_dir = None
+ if do_convert:
+ convert_dir = get_transcode_temp_directory()
+ filename = os.path.basename(full_input_path)
+ convert_for_ffmpeg(
+ full_input_path,
+ convert_dir,
+ None,
+ None,
+ self.log
+ )
+ full_input_path = os.path.join(convert_dir, filename)
filename = os.path.splitext(input_file)[0]
if not filename.endswith('.'):
@@ -124,29 +124,45 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin):
)
except RuntimeError as exp:
if "Compression" in str(exp):
- self.log.debug("Unsupported compression on input files. " +
- "Skipping!!!")
+ self.log.debug(
+ "Unsupported compression on input files. Skipping!!!"
+ )
return
self.log.warning("Conversion crashed", exc_info=True)
raise
- if "representations" not in instance.data:
- instance.data["representations"] = []
-
- representation = {
- 'name': 'thumbnail',
- 'ext': 'jpg',
- 'files': jpeg_file,
+ new_repre = {
+ "name": "thumbnail",
+ "ext": "jpg",
+ "files": jpeg_file,
"stagingDir": stagingdir,
"thumbnail": True,
- "tags": ['thumbnail']
+ "tags": ["thumbnail"]
}
# adding representation
- self.log.debug("Adding: {}".format(representation))
- representations_new.append(representation)
+ self.log.debug("Adding: {}".format(new_repre))
+ instance.data["representations"].append(new_repre)
- if do_decompress and os.path.exists(decompressed_dir):
- shutil.rmtree(decompressed_dir)
+ # Cleanup temp folder
+ if convert_dir is not None and os.path.exists(convert_dir):
+ shutil.rmtree(convert_dir)
- instance.data["representations"] = representations_new
+ def _get_filtered_repres(self, instance):
+ filtered_repres = []
+ src_repres = instance.data.get("representations") or []
+ for repre in src_repres:
+ self.log.debug(repre)
+ tags = repre.get("tags") or []
+ valid = "review" in tags or "thumb-nuke" in tags
+ if not valid:
+ continue
+
+ if not repre.get("files"):
+ self.log.info((
+ "Representation \"{}\" don't have files. Skipping"
+ ).format(repre["name"]))
+ continue
+
+ filtered_repres.append(repre)
+ return filtered_repres
diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py
index 3ab6ffd489..b6c2e49385 100644
--- a/openpype/plugins/publish/extract_review.py
+++ b/openpype/plugins/publish/extract_review.py
@@ -2,6 +2,7 @@ import os
import re
import copy
import json
+import shutil
from abc import ABCMeta, abstractmethod
import six
@@ -16,9 +17,10 @@ from openpype.lib import (
path_to_subprocess_arg,
- should_decompress,
- get_decompress_dir,
- decompress
+ should_convert_for_ffmpeg,
+ convert_for_ffmpeg,
+ get_transcode_temp_directory,
+ get_transcode_temp_directory
)
import speedcopy
@@ -71,18 +73,6 @@ class ExtractReview(pyblish.api.InstancePlugin):
if not instance.data.get("review", True):
return
- # ffmpeg doesn't support multipart exrs
- if instance.data.get("multipartExr") is True:
- instance_label = (
- getattr(instance, "label", None)
- or instance.data.get("label")
- or instance.data.get("name")
- )
- self.log.info((
- "Instance \"{}\" contain \"multipartExr\". Skipped."
- ).format(instance_label))
- return
-
# Run processing
self.main_process(instance)
@@ -92,7 +82,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
if "delete" in tags and "thumbnail" not in tags:
instance.data["representations"].remove(repre)
- def main_process(self, instance):
+ def _get_outputs_for_instance(self, instance):
host_name = instance.context.data["hostName"]
task_name = os.environ["AVALON_TASK"]
family = self.main_family_from_instance(instance)
@@ -114,24 +104,25 @@ class ExtractReview(pyblish.api.InstancePlugin):
self.log.debug("Matching profile: \"{}\"".format(json.dumps(profile)))
instance_families = self.families_from_instance(instance)
- _profile_outputs = self.filter_outputs_by_families(
+ filtered_outputs = self.filter_outputs_by_families(
profile, instance_families
)
- if not _profile_outputs:
+ # Store `filename_suffix` to save arguments
+ profile_outputs = []
+ for filename_suffix, definition in filtered_outputs.items():
+ definition["filename_suffix"] = filename_suffix
+ profile_outputs.append(definition)
+
+ if not filtered_outputs:
self.log.info((
"Skipped instance. All output definitions from selected"
" profile does not match to instance families. \"{}\""
).format(str(instance_families)))
- return
+ return profile_outputs
- # Store `filename_suffix` to save arguments
- profile_outputs = []
- for filename_suffix, definition in _profile_outputs.items():
- definition["filename_suffix"] = filename_suffix
- profile_outputs.append(definition)
-
- # Loop through representations
- for repre in tuple(instance.data["representations"]):
+ def _get_outputs_per_representations(self, instance, profile_outputs):
+ outputs_per_representations = []
+ for repre in instance.data["representations"]:
repre_name = str(repre.get("name"))
tags = repre.get("tags") or []
if "review" not in tags:
@@ -173,6 +164,80 @@ class ExtractReview(pyblish.api.InstancePlugin):
" tags. \"{}\""
).format(str(tags)))
continue
+ outputs_per_representations.append((repre, outputs))
+ return outputs_per_representations
+
+ @staticmethod
+ def get_instance_label(instance):
+ return (
+ getattr(instance, "label", None)
+ or instance.data.get("label")
+ or instance.data.get("name")
+ or str(instance)
+ )
+
+ def main_process(self, instance):
+ instance_label = self.get_instance_label(instance)
+ self.log.debug("Processing instance \"{}\"".format(instance_label))
+ profile_outputs = self._get_outputs_for_instance(instance)
+ if not profile_outputs:
+ return
+
+ # Loop through representations
+ outputs_per_repres = self._get_outputs_per_representations(
+ instance, profile_outputs
+ )
+ for repre, outputs in outputs_per_repres:
+ # Check if input should be preconverted before processing
+ # Store original staging dir (it's value may change)
+ src_repre_staging_dir = repre["stagingDir"]
+ # Receive filepath to first file in representation
+ first_input_path = None
+ if not self.input_is_sequence(repre):
+ first_input_path = os.path.join(
+ src_repre_staging_dir, repre["files"]
+ )
+ else:
+ for filename in repre["files"]:
+ first_input_path = os.path.join(
+ src_repre_staging_dir, filename
+ )
+ break
+
+ # Skip if file is not set
+ if first_input_path is None:
+ self.log.warning((
+ "Representation \"{}\" have empty files. Skipped."
+ ).format(repre["name"]))
+ continue
+
+ # Determine if representation requires pre conversion for ffmpeg
+ do_convert = should_convert_for_ffmpeg(first_input_path)
+ # If result is None the requirement of conversion can't be
+ # determined
+ if do_convert is None:
+ self.log.info((
+ "Can't determine if representation requires conversion."
+ " Skipped."
+ ))
+ continue
+
+ # Do conversion if needed
+ # - change staging dir of source representation
+ # - must be set back after output definitions processing
+ if do_convert:
+ new_staging_dir = get_transcode_temp_directory()
+ repre["stagingDir"] = new_staging_dir
+
+ frame_start = instance.data["frameStart"]
+ frame_end = instance.data["frameEnd"]
+ convert_for_ffmpeg(
+ first_input_path,
+ new_staging_dir,
+ frame_start,
+ frame_end,
+ self.log
+ )
for _output_def in outputs:
output_def = copy.deepcopy(_output_def)
@@ -185,6 +250,10 @@ class ExtractReview(pyblish.api.InstancePlugin):
# Create copy of representation
new_repre = copy.deepcopy(repre)
+ # Make sure new representation has origin staging dir
+ # - this is because source representation may change
+ # it's staging dir because of ffmpeg conversion
+ new_repre["stagingDir"] = src_repre_staging_dir
# Remove "delete" tag from new repre if there is
if "delete" in new_repre["tags"]:
@@ -276,6 +345,14 @@ class ExtractReview(pyblish.api.InstancePlugin):
)
instance.data["representations"].append(new_repre)
+ # Cleanup temp staging dir after procesisng of output definitions
+ if do_convert:
+ temp_dir = repre["stagingDir"]
+ shutil.rmtree(temp_dir)
+ # Set staging dir of source representation back to previous
+ # value
+ repre["stagingDir"] = src_repre_staging_dir
+
def input_is_sequence(self, repre):
"""Deduce from representation data if input is sequence."""
# TODO GLOBAL ISSUE - Find better way how to find out if input
@@ -405,35 +482,9 @@ class ExtractReview(pyblish.api.InstancePlugin):
value for value in _ffmpeg_audio_filters if value.strip()
]
- if isinstance(new_repre['files'], list):
- input_files_urls = [os.path.join(new_repre["stagingDir"], f) for f
- in new_repre['files']]
- test_path = input_files_urls[0]
- else:
- test_path = os.path.join(
- new_repre["stagingDir"], new_repre['files'])
- do_decompress = should_decompress(test_path)
-
- if do_decompress:
- # change stagingDir, decompress first
- # calculate all paths with modified directory, used on too many
- # places
- # will be purged by cleanup.py automatically
- orig_staging_dir = new_repre["stagingDir"]
- new_repre["stagingDir"] = get_decompress_dir()
-
# Prepare input and output filepaths
self.input_output_paths(new_repre, output_def, temp_data)
- if do_decompress:
- input_file = temp_data["full_input_path"].\
- replace(new_repre["stagingDir"], orig_staging_dir)
-
- decompress(new_repre["stagingDir"], input_file,
- temp_data["frame_start"],
- temp_data["frame_end"],
- self.log)
-
# Set output frames len to 1 when ouput is single image
if (
temp_data["output_ext_is_image"]
@@ -744,13 +795,14 @@ class ExtractReview(pyblish.api.InstancePlugin):
"sequence_file" (if output is sequence) keys to new representation.
"""
- staging_dir = new_repre["stagingDir"]
repre = temp_data["origin_repre"]
+ src_staging_dir = repre["stagingDir"]
+ dst_staging_dir = new_repre["stagingDir"]
if temp_data["input_is_sequence"]:
collections = clique.assemble(repre["files"])[0]
full_input_path = os.path.join(
- staging_dir,
+ src_staging_dir,
collections[0].format("{head}{padding}{tail}")
)
@@ -760,12 +812,12 @@ class ExtractReview(pyblish.api.InstancePlugin):
# Make sure to have full path to one input file
full_input_path_single_file = os.path.join(
- staging_dir, repre["files"][0]
+ src_staging_dir, repre["files"][0]
)
else:
full_input_path = os.path.join(
- staging_dir, repre["files"]
+ src_staging_dir, repre["files"]
)
filename = os.path.splitext(repre["files"])[0]
@@ -811,27 +863,27 @@ class ExtractReview(pyblish.api.InstancePlugin):
new_repre["sequence_file"] = repr_file
full_output_path = os.path.join(
- staging_dir, filename_base, repr_file
+ dst_staging_dir, filename_base, repr_file
)
else:
repr_file = "{}_{}.{}".format(
filename, filename_suffix, output_ext
)
- full_output_path = os.path.join(staging_dir, repr_file)
+ full_output_path = os.path.join(dst_staging_dir, repr_file)
new_repre_files = repr_file
# Store files to representation
new_repre["files"] = new_repre_files
# Make sure stagingDire exists
- staging_dir = os.path.normpath(os.path.dirname(full_output_path))
- if not os.path.exists(staging_dir):
- self.log.debug("Creating dir: {}".format(staging_dir))
- os.makedirs(staging_dir)
+ dst_staging_dir = os.path.normpath(os.path.dirname(full_output_path))
+ if not os.path.exists(dst_staging_dir):
+ self.log.debug("Creating dir: {}".format(dst_staging_dir))
+ os.makedirs(dst_staging_dir)
# Store stagingDir to representaion
- new_repre["stagingDir"] = staging_dir
+ new_repre["stagingDir"] = dst_staging_dir
# Store paths to temp data
temp_data["full_input_path"] = full_input_path
diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py
index 519e7c285b..e25b56744e 100644
--- a/openpype/pype_commands.py
+++ b/openpype/pype_commands.py
@@ -216,6 +216,7 @@ class PypeCommands:
task_name,
app_name
)
+ print("env:: {}".format(env))
os.environ.update(env)
os.environ["OPENPYPE_PUBLISH_DATA"] = batch_dir
@@ -304,13 +305,16 @@ class PypeCommands:
log.info("Publish finished.")
@staticmethod
- def extractenvironments(output_json_path, project, asset, task, app):
- env = os.environ.copy()
+ def extractenvironments(
+ output_json_path, project, asset, task, app, env_group
+ ):
if all((project, asset, task, app)):
from openpype.api import get_app_environments_for_context
env = get_app_environments_for_context(
- project, asset, task, app, env
+ project, asset, task, app, env_group
)
+ else:
+ env = os.environ.copy()
output_dir = os.path.dirname(output_json_path)
if not os.path.exists(output_dir):
@@ -340,7 +344,8 @@ class PypeCommands:
def validate_jsons(self):
pass
- def run_tests(self, folder, mark, pyargs):
+ def run_tests(self, folder, mark, pyargs,
+ test_data_folder, persist, app_variant):
"""
Runs tests from 'folder'
@@ -348,25 +353,39 @@ class PypeCommands:
folder (str): relative path to folder with tests
mark (str): label to run tests marked by it (slow etc)
pyargs (str): package path to test
+ test_data_folder (str): url to unzipped folder of test data
+ persist (bool): True if keep test db and published after test
+ end
+ app_variant (str): variant (eg 2020 for AE), empty if use
+ latest installed version
"""
print("run_tests")
- import subprocess
-
if folder:
folder = " ".join(list(folder))
else:
folder = "../tests"
- mark_str = pyargs_str = ''
+ # disable warnings and show captured stdout even if success
+ args = ["--disable-pytest-warnings", "-rP", folder]
+
if mark:
- mark_str = "-m {}".format(mark)
+ args.extend(["-m", mark])
if pyargs:
- pyargs_str = "--pyargs {}".format(pyargs)
+ args.extend(["--pyargs", pyargs])
- cmd = "pytest {} {} {}".format(folder, mark_str, pyargs_str)
- print("Running {}".format(cmd))
- subprocess.run(cmd)
+ if persist:
+ args.extend(["--test_data_folder", test_data_folder])
+
+ if persist:
+ args.extend(["--persist", persist])
+
+ if app_variant:
+ args.extend(["--app_variant", app_variant])
+
+ print("run_tests args: {}".format(args))
+ import pytest
+ pytest.main(args)
def syncserver(self, active_site):
"""Start running sync_server in background."""
diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py
index 68f4728bc7..3fc1412e62 100644
--- a/openpype/scripts/otio_burnin.py
+++ b/openpype/scripts/otio_burnin.py
@@ -161,6 +161,23 @@ def _dnxhd_codec_args(stream_data, source_ffmpeg_cmd):
return output
+def _mxf_format_args(ffprobe_data, source_ffmpeg_cmd):
+ input_format = ffprobe_data["format"]
+ format_tags = input_format.get("tags") or {}
+ product_name = format_tags.get("product_name") or ""
+ output = []
+ if "opatom" in product_name.lower():
+ output.extend(["-f", "mxf_opatom"])
+ return output
+
+
+def get_format_args(ffprobe_data, source_ffmpeg_cmd):
+ input_format = ffprobe_data.get("format") or {}
+ if input_format.get("format_name") == "mxf":
+ return _mxf_format_args(ffprobe_data, source_ffmpeg_cmd)
+ return []
+
+
def get_codec_args(ffprobe_data, source_ffmpeg_cmd):
stream_data = ffprobe_data["streams"][0]
codec_name = stream_data.get("codec_name")
@@ -342,7 +359,8 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins):
if frame_start is None:
replacement_final = replacement_size = str(MISSING_KEY_VALUE)
else:
- replacement_final = "%{eif:n+" + str(frame_start) + ":d}"
+ replacement_final = "%{eif:n+" + str(frame_start) + ":d:" + \
+ str(len(str(frame_end))) + "}"
replacement_size = str(frame_end)
final_text = final_text.replace(
@@ -595,9 +613,9 @@ def burnins_from_data(
if source_timecode is None:
source_timecode = stream.get("tags", {}).get("timecode")
+ # Use "format" key from ffprobe data
+ # - this is used e.g. in mxf extension
if source_timecode is None:
- # Use "format" key from ffprobe data
- # - this is used e.g. in mxf extension
input_format = burnin.ffprobe_data.get("format") or {}
source_timecode = input_format.get("timecode")
if source_timecode is None:
@@ -692,6 +710,9 @@ def burnins_from_data(
ffmpeg_args.append("-g 1")
else:
+ ffmpeg_args.extend(
+ get_format_args(burnin.ffprobe_data, source_ffmpeg_cmd)
+ )
ffmpeg_args.extend(
get_codec_args(burnin.ffprobe_data, source_ffmpeg_cmd)
)
diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json
index f4b9760fe1..b75b0168ec 100644
--- a/openpype/settings/defaults/project_settings/maya.json
+++ b/openpype/settings/defaults/project_settings/maya.json
@@ -43,7 +43,8 @@
"defaults": [
"Main"
],
- "aov_separator": "underscore"
+ "aov_separator": "underscore",
+ "default_render_image_folder": "renders"
},
"CreateAnimation": {
"enabled": true,
diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json
index 0c24c943ec..db9bf87268 100644
--- a/openpype/settings/defaults/project_settings/photoshop.json
+++ b/openpype/settings/defaults/project_settings/photoshop.json
@@ -7,11 +7,6 @@
}
},
"publish": {
- "ValidateContainers": {
- "enabled": true,
- "optional": true,
- "active": true
- },
"CollectRemoteInstances": {
"color_code_mapping": [
{
@@ -22,6 +17,15 @@
}
]
},
+ "ValidateContainers": {
+ "enabled": true,
+ "optional": true,
+ "active": true
+ },
+ "ValidateNaming": {
+ "invalid_chars": "[ \\\\/+\\*\\?\\(\\)\\[\\]\\{\\}:,]",
+ "replace_char": "_"
+ },
"ExtractImage": {
"formats": [
"png",
diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json
index cc80a94d3f..1cbe09f576 100644
--- a/openpype/settings/defaults/system_settings/applications.json
+++ b/openpype/settings/defaults/system_settings/applications.json
@@ -107,7 +107,10 @@
"windows": "",
"darwin": "",
"linux": ""
- }
+ },
+ "FLAME_WIRETAP_HOSTNAME": "",
+ "FLAME_WIRETAP_VOLUME": "stonefs",
+ "FLAME_WIRETAP_GROUP": "staff"
},
"variants": {
"2021": {
@@ -139,7 +142,7 @@
"icon": "{}/app_icons/nuke.png",
"host_name": "nuke",
"environment": {
- "NUKE_PATH": "{OPENPYPE_STUDIO_PLUGINS}/nuke"
+ "NUKE_PATH": ["{NUKE_PATH}", "{OPENPYPE_STUDIO_PLUGINS}/nuke"]
},
"variants": {
"13-0": {
@@ -245,7 +248,7 @@
"icon": "{}/app_icons/nuke.png",
"host_name": "nuke",
"environment": {
- "NUKE_PATH": "{OPENPYPE_STUDIO_PLUGINS}/nuke"
+ "NUKE_PATH": ["{NUKE_PATH}", "{OPENPYPE_STUDIO_PLUGINS}/nuke"]
},
"variants": {
"13-0": {
@@ -502,7 +505,7 @@
"environment": {}
},
"__dynamic_keys_labels__": {
- "13-0": "13.0 (Testing only)",
+ "13-0": "13.0",
"12-2": "12.2",
"12-0": "12.0",
"11-3": "11.3",
@@ -639,7 +642,7 @@
"environment": {}
},
"__dynamic_keys_labels__": {
- "13-0": "13.0 (Testing only)",
+ "13-0": "13.0",
"12-2": "12.2",
"12-0": "12.0",
"11-3": "11.3",
@@ -1098,6 +1101,23 @@
"linux": []
},
"environment": {}
+ },
+ "2022": {
+ "enabled": true,
+ "variant_label": "2022",
+ "executables": {
+ "windows": [
+ "C:\\Program Files\\Adobe\\Adobe After Effects 2022\\Support Files\\AfterFX.exe"
+ ],
+ "darwin": [],
+ "linux": []
+ },
+ "arguments": {
+ "windows": [],
+ "darwin": [],
+ "linux": []
+ },
+ "environment": {}
}
}
},
diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py
index 341968bd75..cbc042d29d 100644
--- a/openpype/settings/entities/base_entity.py
+++ b/openpype/settings/entities/base_entity.py
@@ -235,6 +235,11 @@ class BaseItemEntity(BaseEntity):
"""Return system settings entity."""
pass
+ @abstractmethod
+ def has_child_with_key(self, key):
+ """Entity contains key as children."""
+ pass
+
def schema_validations(self):
"""Validate schema of entity and it's hierachy.
diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py
index 5f1c172f31..92512a6668 100644
--- a/openpype/settings/entities/dict_conditional.py
+++ b/openpype/settings/entities/dict_conditional.py
@@ -107,6 +107,9 @@ class DictConditionalEntity(ItemEntity):
for _key, _value in new_value.items():
self.non_gui_children[self.current_enum][_key].set(_value)
+ def has_child_with_key(self, key):
+ return key in self.keys()
+
def _item_initialization(self):
self._default_metadata = NOT_SET
self._studio_override_metadata = NOT_SET
diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py
index 6131fa2ac7..c477a0eb0f 100644
--- a/openpype/settings/entities/dict_immutable_keys_entity.py
+++ b/openpype/settings/entities/dict_immutable_keys_entity.py
@@ -205,6 +205,9 @@ class DictImmutableKeysEntity(ItemEntity):
)
self.show_borders = self.schema_data.get("show_borders", True)
+ def has_child_with_key(self, key):
+ return key in self.non_gui_children
+
def collect_static_entities_by_path(self):
output = {}
if self.is_dynamic_item or self.is_in_dynamic_item:
diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py
index cff346e9ea..08b0f75649 100644
--- a/openpype/settings/entities/dict_mutable_keys_entity.py
+++ b/openpype/settings/entities/dict_mutable_keys_entity.py
@@ -60,6 +60,12 @@ class DictMutableKeysEntity(EndpointEntity):
def pop(self, key, *args, **kwargs):
if key in self.required_keys:
raise RequiredKeyModified(self.path, key)
+
+ if self._override_state is OverrideState.STUDIO:
+ self._has_studio_override = True
+ elif self._override_state is OverrideState.PROJECT:
+ self._has_project_override = True
+
result = self.children_by_key.pop(key, *args, **kwargs)
self.on_change()
return result
@@ -191,6 +197,9 @@ class DictMutableKeysEntity(EndpointEntity):
child_entity = self.children_by_key[key]
self.set_child_label(child_entity, label)
+ def has_child_with_key(self, key):
+ return key in self.children_by_key
+
def _item_initialization(self):
self._default_metadata = {}
self._studio_override_metadata = {}
diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py
index ab3cebbd42..fb6099e82a 100644
--- a/openpype/settings/entities/enum_entity.py
+++ b/openpype/settings/entities/enum_entity.py
@@ -154,7 +154,8 @@ class HostsEnumEntity(BaseEnumEntity):
"resolve",
"tvpaint",
"unreal",
- "standalonepublisher"
+ "standalonepublisher",
+ "webpublisher"
]
def _item_initialization(self):
diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py
index a0598d405e..16893747a6 100644
--- a/openpype/settings/entities/input_entities.py
+++ b/openpype/settings/entities/input_entities.py
@@ -118,6 +118,9 @@ class InputEntity(EndpointEntity):
return self.value == other.value
return self.value == other
+ def has_child_with_key(self, key):
+ return False
+
def get_child_path(self, child_obj):
raise TypeError("{} can't have children".format(
self.__class__.__name__
diff --git a/openpype/settings/entities/item_entities.py b/openpype/settings/entities/item_entities.py
index ff0a982900..9c6f428b97 100644
--- a/openpype/settings/entities/item_entities.py
+++ b/openpype/settings/entities/item_entities.py
@@ -1,3 +1,7 @@
+import re
+
+import six
+
from .lib import (
NOT_SET,
STRING_TYPE,
@@ -48,6 +52,9 @@ class PathEntity(ItemEntity):
raise AttributeError(self.attribute_error_msg.format("items"))
return self.child_obj.items()
+ def has_child_with_key(self, key):
+ return self.child_obj.has_child_with_key(key)
+
def _item_initialization(self):
if self.group_item is None and not self.is_group:
self.is_group = True
@@ -197,6 +204,7 @@ class PathEntity(ItemEntity):
class ListStrictEntity(ItemEntity):
schema_types = ["list-strict"]
+ _key_regex = re.compile(r"[0-9]+")
def __getitem__(self, idx):
if not isinstance(idx, int):
@@ -216,6 +224,19 @@ class ListStrictEntity(ItemEntity):
return self.children[idx]
return default
+ def has_child_with_key(self, key):
+ if (
+ key
+ and isinstance(key, six.string_types)
+ and self._key_regex.match(key)
+ ):
+ key = int(key)
+
+ if not isinstance(key, int):
+ return False
+
+ return 0 <= key < len(self.children)
+
def _item_initialization(self):
self.valid_value_types = (list, )
self.require_key = True
diff --git a/openpype/settings/entities/list_entity.py b/openpype/settings/entities/list_entity.py
index 5d89a81351..0268c208bb 100644
--- a/openpype/settings/entities/list_entity.py
+++ b/openpype/settings/entities/list_entity.py
@@ -1,4 +1,6 @@
import copy
+import six
+import re
from . import (
BaseEntity,
EndpointEntity
@@ -21,6 +23,7 @@ class ListEntity(EndpointEntity):
"collapsible": True,
"collapsed": False
}
+ _key_regex = re.compile(r"[0-9]+")
def __iter__(self):
for item in self.children:
@@ -144,6 +147,19 @@ class ListEntity(EndpointEntity):
)
self.on_change()
+ def has_child_with_key(self, key):
+ if (
+ key
+ and isinstance(key, six.string_types)
+ and self._key_regex.match(key)
+ ):
+ key = int(key)
+
+ if not isinstance(key, int):
+ return False
+
+ return 0 <= key < len(self.children)
+
def _convert_to_valid_type(self, value):
if isinstance(value, (set, tuple)):
return list(value)
diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py
index b8baed8a93..687784a359 100644
--- a/openpype/settings/entities/root_entities.py
+++ b/openpype/settings/entities/root_entities.py
@@ -127,6 +127,9 @@ class RootEntity(BaseItemEntity):
for _key, _value in new_value.items():
self.non_gui_children[_key].set(_value)
+ def has_child_with_key(self, key):
+ return key in self.non_gui_children
+
def keys(self):
return self.non_gui_children.keys()
diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json
index ca388de60c..51ea5b3fe7 100644
--- a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json
+++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json
@@ -33,16 +33,6 @@
"key": "publish",
"label": "Publish plugins",
"children": [
- {
- "type": "schema_template",
- "name": "template_publish_plugin",
- "template_data": [
- {
- "key": "ValidateContainers",
- "label": "ValidateContainers"
- }
- ]
- },
{
"type": "dict",
"collapsible": true,
@@ -108,6 +98,38 @@
}
]
},
+ {
+ "type": "schema_template",
+ "name": "template_publish_plugin",
+ "template_data": [
+ {
+ "key": "ValidateContainers",
+ "label": "ValidateContainers"
+ }
+ ]
+ },
+ {
+ "type": "dict",
+ "collapsible": true,
+ "key": "ValidateNaming",
+ "label": "Validate naming of subsets and layers",
+ "children": [
+ {
+ "type": "label",
+ "label": "Subset cannot contain invalid characters or extract to file would fail"
+ },
+ {
+ "type": "text",
+ "key": "invalid_chars",
+ "label": "Regex pattern of invalid characters"
+ },
+ {
+ "type": "text",
+ "key": "replace_char",
+ "label": "Replacement character"
+ }
+ ]
+ },
{
"type": "dict",
"collapsible": true,
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json
index e50357cc40..088d5d1f96 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json
@@ -58,6 +58,11 @@
{"underscore": "_ (underscore)"},
{"dot": ". (dot)"}
]
+ },
+ {
+ "type": "text",
+ "key": "default_render_image_folder",
+ "label": "Default render image folder"
}
]
},
diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_aftereffects.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_aftereffects.json
index 6c36a9bb8a..334c9aa235 100644
--- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_aftereffects.json
+++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_aftereffects.json
@@ -36,6 +36,11 @@
"app_variant_label": "2021",
"app_variant": "2021",
"variant_skip_paths": ["use_python_2"]
+ },
+ {
+ "app_variant_label": "2022",
+ "app_variant": "2022",
+ "variant_skip_paths": ["use_python_2"]
}
]
}
diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py
index ff75562413..43489aecfd 100644
--- a/openpype/settings/lib.py
+++ b/openpype/settings/lib.py
@@ -933,8 +933,10 @@ def get_general_environments():
# - prevent to use `get_system_settings` where `get_default_settings`
# is used
default_values = load_openpype_default_settings()
+ system_settings = default_values["system_settings"]
studio_overrides = get_studio_system_settings_overrides()
- result = apply_overrides(default_values, studio_overrides)
+
+ result = apply_overrides(system_settings, studio_overrides)
environments = result["general"]["environment"]
clear_metadata_from_settings(environments)
diff --git a/openpype/style/style.css b/openpype/style/style.css
index a60c3592d7..4159fe1676 100644
--- a/openpype/style/style.css
+++ b/openpype/style/style.css
@@ -1044,16 +1044,45 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
color: {color:settings:label-fg};
}
#SettingsLabel:hover {color: {color:settings:label-fg-hover};}
-#SettingsLabel[state="studio"] {color: {color:settings:studio-light};}
-#SettingsLabel[state="studio"]:hover {color: {color:settings:studio-label-hover};}
-#SettingsLabel[state="modified"] {color: {color:settings:modified-mid};}
-#SettingsLabel[state="modified"]:hover {color: {color:settings:modified-light};}
-#SettingsLabel[state="overriden-modified"] {color: {color:settings:modified-mid};}
-#SettingsLabel[state="overriden-modified"]:hover {color: {color:settings:modified-light};}
-#SettingsLabel[state="overriden"] {color: {color:settings:project-mid};}
-#SettingsLabel[state="overriden"]:hover {color: {color:settings:project-light};}
-#SettingsLabel[state="invalid"] {color:{color:settings:invalid-dark};}
-#SettingsLabel[state="invalid"]:hover {color: {color:settings:invalid-dark};}
+
+#ExpandLabel {
+ font-weight: bold;
+ color: {color:settings:label-fg};
+}
+#ExpandLabel:hover {
+ color: {color:settings:label-fg-hover};
+}
+
+#ExpandLabel[state="studio"], #SettingsLabel[state="studio"] {
+ color: {color:settings:studio-light};
+}
+#ExpandLabel[state="studio"]:hover, #SettingsLabel[state="studio"]:hover {
+ color: {color:settings:studio-label-hover};
+}
+#ExpandLabel[state="modified"], #SettingsLabel[state="modified"] {
+ color: {color:settings:modified-mid};
+}
+#ExpandLabel[state="modified"]:hover, #SettingsLabel[state="modified"]:hover {
+ color: {color:settings:modified-light};
+}
+#ExpandLabel[state="overriden-modified"], #SettingsLabel[state="overriden-modified"] {
+ color: {color:settings:modified-mid};
+}
+#ExpandLabel[state="overriden-modified"]:hover, #SettingsLabel[state="overriden-modified"]:hover {
+ color: {color:settings:modified-light};
+}
+#ExpandLabel[state="overriden"], #SettingsLabel[state="overriden"] {
+ color: {color:settings:project-mid};
+}
+#ExpandLabel[state="overriden"]:hover, #SettingsLabel[state="overriden"]:hover {
+ color: {color:settings:project-light};
+}
+#ExpandLabel[state="invalid"], #SettingsLabel[state="invalid"] {
+ color:{color:settings:invalid-dark};
+}
+#ExpandLabel[state="invalid"]:hover, #SettingsLabel[state="invalid"]:hover {
+ color: {color:settings:invalid-dark};
+}
/* TODO Replace these with explicit widget types if possible */
#SettingsMainWidget QWidget[input-state="modified"] {
@@ -1085,14 +1114,6 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
#DictKey[state="modified"] {border-color: {color:settings:modified-mid};}
#DictKey[state="invalid"] {border-color: {color:settings:invalid-dark};}
-#ExpandLabel {
- font-weight: bold;
- color: {color:settings:label-fg};
-}
-#ExpandLabel:hover {
- color: {color:settings:label-fg-hover};
-}
-
#ContentWidget {
background-color: transparent;
}
@@ -1194,3 +1215,17 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
#ImageButton:disabled {
background: {color:bg-buttons-disabled};
}
+
+/* Input field that looks like disabled
+- QAbstractSpinBox, QLineEdit, QPlainTextEdit, QTextEdit
+- usage: QLineEdit that is not editable but has selectable color
+ */
+#LikeDisabledInput {
+ background: {color:bg-inputs-disabled};
+}
+#LikeDisabledInput:hover {
+ border-color: {color:border};
+}
+#LikeDisabledInput:focus {
+ border-color: {color:border};
+}
diff --git a/openpype/tools/launcher/window.py b/openpype/tools/launcher/window.py
index c8acbe77c2..a8f65894f2 100644
--- a/openpype/tools/launcher/window.py
+++ b/openpype/tools/launcher/window.py
@@ -243,7 +243,11 @@ class LauncherWindow(QtWidgets.QDialog):
# Allow minimize
self.setWindowFlags(
- self.windowFlags() | QtCore.Qt.WindowMinimizeButtonHint
+ QtCore.Qt.Window
+ | QtCore.Qt.CustomizeWindowHint
+ | QtCore.Qt.WindowTitleHint
+ | QtCore.Qt.WindowMinimizeButtonHint
+ | QtCore.Qt.WindowCloseButtonHint
)
project_model = ProjectModel(self.dbcon)
diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py
index b6becc3e9f..583065633b 100644
--- a/openpype/tools/loader/app.py
+++ b/openpype/tools/loader/app.py
@@ -4,7 +4,10 @@ from Qt import QtWidgets, QtCore
from avalon import api, io, pipeline
from openpype import style
-from openpype.tools.utils import lib
+from openpype.tools.utils import (
+ lib,
+ PlaceholderLineEdit
+)
from openpype.tools.utils.assets_widget import MultiSelectAssetsWidget
from .widgets import (
@@ -517,7 +520,7 @@ class SubsetGroupingDialog(QtWidgets.QDialog):
self.subsets = parent._subsets_widget
self.asset_ids = parent.data["state"]["assetIds"]
- name = QtWidgets.QLineEdit()
+ name = PlaceholderLineEdit(self)
name.setPlaceholderText("Remain blank to ungroup..")
# Menu for pre-defined subset groups
diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py
index b4d791b6d5..e4c58a8a2c 100644
--- a/openpype/tools/project_manager/project_manager/widgets.py
+++ b/openpype/tools/project_manager/project_manager/widgets.py
@@ -10,6 +10,7 @@ from openpype.lib import (
PROJECT_NAME_REGEX
)
from openpype.style import load_stylesheet
+from openpype.tools.utils import PlaceholderLineEdit
from avalon.api import AvalonMongoDB
from Qt import QtWidgets, QtCore
@@ -345,7 +346,7 @@ class ConfirmProjectDeletion(QtWidgets.QDialog):
question_label = QtWidgets.QLabel("Are you sure?", self)
- confirm_input = QtWidgets.QLineEdit(self)
+ confirm_input = PlaceholderLineEdit(self)
confirm_input.setPlaceholderText("Type \"Delete\" to confirm...")
cancel_btn = _SameSizeBtns("Cancel", self)
diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py
index fe00ee78d3..2ebcf73d4e 100644
--- a/openpype/tools/publisher/widgets/widgets.py
+++ b/openpype/tools/publisher/widgets/widgets.py
@@ -9,6 +9,7 @@ from avalon.vendor import qtawesome
from openpype.widgets.attribute_defs import create_widget_for_attr_def
from openpype.tools.flickcharm import FlickCharm
+from openpype.tools.utils import PlaceholderLineEdit
from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS
from .models import (
AssetsHierarchyModel,
@@ -396,7 +397,7 @@ class AssetsDialog(QtWidgets.QDialog):
proxy_model.setSourceModel(model)
proxy_model.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
- filter_input = QtWidgets.QLineEdit(self)
+ filter_input = PlaceholderLineEdit(self)
filter_input.setPlaceholderText("Filter assets..")
asset_view = QtWidgets.QTreeView(self)
@@ -934,7 +935,7 @@ class TasksCombobox(QtWidgets.QComboBox):
self.set_selected_items(self._origin_value)
-class VariantInputWidget(QtWidgets.QLineEdit):
+class VariantInputWidget(PlaceholderLineEdit):
"""Input widget for variant."""
value_changed = QtCore.Signal()
diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py
index fb5b0c4e92..bb58813e55 100644
--- a/openpype/tools/publisher/window.py
+++ b/openpype/tools/publisher/window.py
@@ -4,7 +4,7 @@ from openpype import (
resources,
style
)
-
+from openpype.tools.utils import PlaceholderLineEdit
from .control import PublisherController
from .widgets import (
BorderedLabelWidget,
@@ -131,7 +131,7 @@ class PublisherWindow(QtWidgets.QDialog):
subset_content_layout.addWidget(subset_attributes_wrap, 7)
# Footer
- comment_input = QtWidgets.QLineEdit(subset_frame)
+ comment_input = PlaceholderLineEdit(subset_frame)
comment_input.setObjectName("PublishCommentInput")
comment_input.setPlaceholderText(
"Attach a comment to your publish"
diff --git a/openpype/tools/settings/local_settings/apps_widget.py b/openpype/tools/settings/local_settings/apps_widget.py
index 850e009937..28bc726300 100644
--- a/openpype/tools/settings/local_settings/apps_widget.py
+++ b/openpype/tools/settings/local_settings/apps_widget.py
@@ -5,6 +5,7 @@ from .widgets import (
ExpandingWidget
)
from openpype.tools.settings import CHILD_OFFSET
+from openpype.tools.utils import PlaceholderLineEdit
class AppVariantWidget(QtWidgets.QWidget):
@@ -45,7 +46,7 @@ class AppVariantWidget(QtWidgets.QWidget):
content_layout.addWidget(warn_label)
return
- executable_input_widget = QtWidgets.QLineEdit(content_widget)
+ executable_input_widget = PlaceholderLineEdit(content_widget)
executable_input_widget.setPlaceholderText(self.exec_placeholder)
content_layout.addWidget(executable_input_widget)
@@ -64,8 +65,9 @@ class AppVariantWidget(QtWidgets.QWidget):
for item in studio_executables:
path_widget = QtWidgets.QLineEdit(content_widget)
+ path_widget.setObjectName("LikeDisabledInput")
path_widget.setText(item.value)
- path_widget.setEnabled(False)
+ path_widget.setReadOnly(True)
content_layout.addWidget(path_widget)
def update_local_settings(self, value):
diff --git a/openpype/tools/settings/local_settings/general_widget.py b/openpype/tools/settings/local_settings/general_widget.py
index 5bb2bcf378..35add7573e 100644
--- a/openpype/tools/settings/local_settings/general_widget.py
+++ b/openpype/tools/settings/local_settings/general_widget.py
@@ -3,6 +3,7 @@ import getpass
from Qt import QtWidgets, QtCore
from openpype.lib import is_admin_password_required
from openpype.widgets import PasswordDialog
+from openpype.tools.utils import PlaceholderLineEdit
class LocalGeneralWidgets(QtWidgets.QWidget):
@@ -11,7 +12,7 @@ class LocalGeneralWidgets(QtWidgets.QWidget):
self._loading_local_settings = False
- username_input = QtWidgets.QLineEdit(self)
+ username_input = PlaceholderLineEdit(self)
username_input.setPlaceholderText(getpass.getuser())
is_admin_input = QtWidgets.QCheckBox(self)
diff --git a/openpype/tools/settings/local_settings/mongo_widget.py b/openpype/tools/settings/local_settings/mongo_widget.py
index eebafdffdd..3d3dbd0a5d 100644
--- a/openpype/tools/settings/local_settings/mongo_widget.py
+++ b/openpype/tools/settings/local_settings/mongo_widget.py
@@ -6,6 +6,7 @@ from Qt import QtWidgets
from pymongo.errors import ServerSelectionTimeoutError
from openpype.api import change_openpype_mongo_url
+from openpype.tools.utils import PlaceholderLineEdit
class OpenPypeMongoWidget(QtWidgets.QWidget):
@@ -25,7 +26,7 @@ class OpenPypeMongoWidget(QtWidgets.QWidget):
mongo_url_label = QtWidgets.QLabel("OpenPype Mongo URL", self)
# Input
- mongo_url_input = QtWidgets.QLineEdit(self)
+ mongo_url_input = PlaceholderLineEdit(self)
mongo_url_input.setPlaceholderText("< OpenPype Mongo URL >")
mongo_url_input.setText(os.environ["OPENPYPE_MONGO"])
diff --git a/openpype/tools/settings/local_settings/projects_widget.py b/openpype/tools/settings/local_settings/projects_widget.py
index 7e2ad661a0..da45467a4e 100644
--- a/openpype/tools/settings/local_settings/projects_widget.py
+++ b/openpype/tools/settings/local_settings/projects_widget.py
@@ -2,6 +2,7 @@ import platform
import copy
from Qt import QtWidgets, QtCore, QtGui
from openpype.tools.settings.settings import ProjectListWidget
+from openpype.tools.utils import PlaceholderLineEdit
from openpype.settings.constants import (
PROJECT_ANATOMY_KEY,
DEFAULT_PROJECT_KEY
@@ -45,7 +46,7 @@ class DynamicInputItem(QtCore.QObject):
parent
):
super(DynamicInputItem, self).__init__()
- input_widget = QtWidgets.QLineEdit(parent)
+ input_widget = PlaceholderLineEdit(parent)
settings_value = input_def.get("value")
placeholder = input_def.get("placeholder")
diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py
index f8378ed18c..8a420c2447 100644
--- a/openpype/tools/settings/settings/base.py
+++ b/openpype/tools/settings/settings/base.py
@@ -1,9 +1,15 @@
+import sys
import json
+import traceback
from Qt import QtWidgets, QtGui, QtCore
+
+from openpype.settings.entities import ProjectSettings
from openpype.tools.settings import CHILD_OFFSET
+
from .widgets import ExpandingWidget
from .lib import create_deffered_value_change_timer
+from .constants import DEFAULT_PROJECT_LABEL
class BaseWidget(QtWidgets.QWidget):
@@ -110,9 +116,10 @@ class BaseWidget(QtWidgets.QWidget):
return
def discard_changes():
- self.ignore_input_changes.set_ignore(True)
- self.entity.discard_changes()
- self.ignore_input_changes.set_ignore(False)
+ with self.category_widget.working_state_context():
+ self.ignore_input_changes.set_ignore(True)
+ self.entity.discard_changes()
+ self.ignore_input_changes.set_ignore(False)
action = QtWidgets.QAction("Discard changes")
actions_mapping[action] = discard_changes
@@ -124,8 +131,11 @@ class BaseWidget(QtWidgets.QWidget):
if not self.entity.can_trigger_add_to_studio_default:
return
+ def add_to_studio_default():
+ with self.category_widget.working_state_context():
+ self.entity.add_to_studio_default()
action = QtWidgets.QAction("Add to studio default")
- actions_mapping[action] = self.entity.add_to_studio_default
+ actions_mapping[action] = add_to_studio_default
menu.addAction(action)
def _remove_from_studio_default_action(self, menu, actions_mapping):
@@ -133,9 +143,10 @@ class BaseWidget(QtWidgets.QWidget):
return
def remove_from_studio_default():
- self.ignore_input_changes.set_ignore(True)
- self.entity.remove_from_studio_default()
- self.ignore_input_changes.set_ignore(False)
+ with self.category_widget.working_state_context():
+ self.ignore_input_changes.set_ignore(True)
+ self.entity.remove_from_studio_default()
+ self.ignore_input_changes.set_ignore(False)
action = QtWidgets.QAction("Remove from studio default")
actions_mapping[action] = remove_from_studio_default
menu.addAction(action)
@@ -144,8 +155,12 @@ class BaseWidget(QtWidgets.QWidget):
if not self.entity.can_trigger_add_to_project_override:
return
+ def add_to_project_override():
+ with self.category_widget.working_state_context():
+ self.entity.add_to_project_override
+
action = QtWidgets.QAction("Add to project project override")
- actions_mapping[action] = self.entity.add_to_project_override
+ actions_mapping[action] = add_to_project_override
menu.addAction(action)
def _remove_from_project_override_action(self, menu, actions_mapping):
@@ -153,9 +168,11 @@ class BaseWidget(QtWidgets.QWidget):
return
def remove_from_project_override():
- self.ignore_input_changes.set_ignore(True)
- self.entity.remove_from_project_override()
- self.ignore_input_changes.set_ignore(False)
+ with self.category_widget.working_state_context():
+ self.ignore_input_changes.set_ignore(True)
+ self.entity.remove_from_project_override()
+ self.ignore_input_changes.set_ignore(False)
+
action = QtWidgets.QAction("Remove from project override")
actions_mapping[action] = remove_from_project_override
menu.addAction(action)
@@ -257,14 +274,16 @@ class BaseWidget(QtWidgets.QWidget):
# Simple paste value method
def paste_value():
- _set_entity_value(self.entity, value)
+ with self.category_widget.working_state_context():
+ _set_entity_value(self.entity, value)
action = QtWidgets.QAction("Paste", menu)
output.append((action, paste_value))
# Paste value to matchin entity
def paste_value_to_path():
- _set_entity_value(matching_entity, value)
+ with self.category_widget.working_state_context():
+ _set_entity_value(matching_entity, value)
if matching_entity is not None:
action = QtWidgets.QAction("Paste to same place", menu)
@@ -272,6 +291,68 @@ class BaseWidget(QtWidgets.QWidget):
return output
+ def _apply_values_from_project_action(self, menu, actions_mapping):
+ for attr_name in ("project_name", "get_project_names"):
+ if not hasattr(self.category_widget, attr_name):
+ return
+
+ if self.entity.is_dynamic_item or self.entity.is_in_dynamic_item:
+ return
+
+ current_project_name = self.category_widget.project_name
+ project_names = []
+ for project_name in self.category_widget.get_project_names():
+ if project_name != current_project_name:
+ project_names.append(project_name)
+
+ if not project_names:
+ return
+
+ submenu = QtWidgets.QMenu("Apply values from", menu)
+
+ for project_name in project_names:
+ if project_name is None:
+ project_name = DEFAULT_PROJECT_LABEL
+
+ action = QtWidgets.QAction(project_name)
+ submenu.addAction(action)
+ actions_mapping[action] = lambda: self._apply_values_from_project(
+ project_name
+ )
+ menu.addMenu(submenu)
+
+ def _apply_values_from_project(self, project_name):
+ with self.category_widget.working_state_context():
+ try:
+ path_keys = [
+ item
+ for item in self.entity.path.split("/")
+ if item
+ ]
+ entity = ProjectSettings(project_name)
+ for key in path_keys:
+ entity = entity[key]
+ self.entity.set(entity.value)
+
+ except Exception:
+ if project_name is None:
+ project_name = DEFAULT_PROJECT_LABEL
+
+ # TODO better message
+ title = "Applying values failed"
+ msg = "Applying values from project \"{}\" failed.".format(
+ project_name
+ )
+ detail_msg = "".join(
+ traceback.format_exception(*sys.exc_info())
+ )
+ dialog = QtWidgets.QMessageBox(self)
+ dialog.setWindowTitle(title)
+ dialog.setIcon(QtWidgets.QMessageBox.Warning)
+ dialog.setText(msg)
+ dialog.setDetailedText(detail_msg)
+ dialog.exec_()
+
def show_actions_menu(self, event=None):
if event and event.button() != QtCore.Qt.RightButton:
return
@@ -290,6 +371,7 @@ class BaseWidget(QtWidgets.QWidget):
self._remove_from_studio_default_action(menu, actions_mapping)
self._add_to_project_override_action(menu, actions_mapping)
self._remove_from_project_override_action(menu, actions_mapping)
+ self._apply_values_from_project_action(menu, actions_mapping)
ui_actions = []
ui_actions.extend(self._copy_value_actions(menu))
@@ -472,7 +554,9 @@ class GUIWidget(BaseWidget):
def _create_label_ui(self):
label = self.entity["label"]
label_widget = QtWidgets.QLabel(label, self)
+ label_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
label_widget.setObjectName("SettingsLabel")
+ label_widget.linkActivated.connect(self._on_link_activate)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 5, 0, 5)
@@ -488,6 +572,14 @@ class GUIWidget(BaseWidget):
layout.setContentsMargins(5, 5, 5, 5)
layout.addWidget(splitter_item)
+ def _on_link_activate(self, url):
+ if not url.startswith("settings://"):
+ QtGui.QDesktopServices.openUrl(url)
+ return
+
+ path = url.replace("settings://", "")
+ self.category_widget.go_to_fullpath(path)
+
def set_entity_value(self):
pass
diff --git a/openpype/tools/settings/settings/breadcrumbs_widget.py b/openpype/tools/settings/settings/breadcrumbs_widget.py
index d25cbdc8cb..7524bc61f0 100644
--- a/openpype/tools/settings/settings/breadcrumbs_widget.py
+++ b/openpype/tools/settings/settings/breadcrumbs_widget.py
@@ -71,17 +71,35 @@ class SettingsBreadcrumbs(BreadcrumbsModel):
return True
return False
+ def get_valid_path(self, path):
+ if not path:
+ return ""
+
+ path_items = path.split("/")
+ new_path_items = []
+ entity = self.entity
+ for item in path_items:
+ if not entity.has_child_with_key(item):
+ break
+
+ new_path_items.append(item)
+ entity = entity[item]
+
+ return "/".join(new_path_items)
+
def is_valid_path(self, path):
if not path:
return True
path_items = path.split("/")
- try:
- entity = self.entity
- for item in path_items:
- entity = entity[item]
- except Exception:
- return False
+
+ entity = self.entity
+ for item in path_items:
+ if not entity.has_child_with_key(item):
+ return False
+
+ entity = entity[item]
+
return True
@@ -436,6 +454,7 @@ class BreadcrumbsAddressBar(QtWidgets.QFrame):
self.change_path(path)
def change_path(self, path):
+ path = self._model.get_valid_path(path)
if self._model and not self._model.is_valid_path(path):
self._show_address_field()
else:
diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py
index a6e4154b2b..b046085975 100644
--- a/openpype/tools/settings/settings/categories.py
+++ b/openpype/tools/settings/settings/categories.py
@@ -1,6 +1,7 @@
import os
import sys
import traceback
+import contextlib
from enum import Enum
from Qt import QtWidgets, QtCore, QtGui
@@ -81,6 +82,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
state_changed = QtCore.Signal()
saved = QtCore.Signal(QtWidgets.QWidget)
restart_required_trigger = QtCore.Signal()
+ full_path_requested = QtCore.Signal(str, str)
def __init__(self, user_role, parent=None):
super(SettingsCategoryWidget, self).__init__(parent)
@@ -267,6 +269,37 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
# Scroll to widget
self.scroll_widget.ensureWidgetVisible(widget)
+ def go_to_fullpath(self, full_path):
+ """Full path of settings entity which can lead to different category.
+
+ Args:
+ full_path (str): Full path to settings entity. It is expected that
+ path starts with category name ("system_setting" etc.).
+ """
+ if not full_path:
+ return
+ items = full_path.split("/")
+ category = items[0]
+ path = ""
+ if len(items) > 1:
+ path = "/".join(items[1:])
+ self.full_path_requested.emit(category, path)
+
+ def contain_category_key(self, category):
+ """Parent widget ask if category of full path lead to this widget.
+
+ Args:
+ category (str): The category name.
+
+ Returns:
+ bool: Passed category lead to this widget.
+ """
+ return False
+
+ def set_category_path(self, category, path):
+ """Change path of widget based on category full path."""
+ pass
+
def set_path(self, path):
self.breadcrumbs_widget.set_path(path)
@@ -309,6 +342,12 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
)
self.content_layout.addWidget(widget, 0)
+ @contextlib.contextmanager
+ def working_state_context(self):
+ self.set_state(CategoryState.Working)
+ yield
+ self.set_state(CategoryState.Idle)
+
def save(self):
if not self.items_are_valid():
return
@@ -548,6 +587,14 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
class SystemWidget(SettingsCategoryWidget):
+ def contain_category_key(self, category):
+ if category == "system_settings":
+ return True
+ return False
+
+ def set_category_path(self, category, path):
+ self.breadcrumbs_widget.change_path(path)
+
def _create_root_entity(self):
self.entity = SystemSettings(set_studio_state=False)
self.entity.on_change_callbacks.append(self._on_entity_change)
@@ -584,6 +631,21 @@ class SystemWidget(SettingsCategoryWidget):
class ProjectWidget(SettingsCategoryWidget):
+ def contain_category_key(self, category):
+ if category in ("project_settings", "project_anatomy"):
+ return True
+ return False
+
+ def set_category_path(self, category, path):
+ if path:
+ path_items = path.split("/")
+ if path_items[0] not in ("project_settings", "project_anatomy"):
+ path = "/".join([category, path])
+ else:
+ path = category
+
+ self.breadcrumbs_widget.change_path(path)
+
def initialize_attributes(self):
self.project_name = None
@@ -599,6 +661,14 @@ class ProjectWidget(SettingsCategoryWidget):
self.project_list_widget = project_list_widget
+ def get_project_names(self):
+ if (
+ self.modify_defaults_checkbox
+ and self.modify_defaults_checkbox.isChecked()
+ ):
+ return []
+ return self.project_list_widget.get_project_names()
+
def on_saved(self, saved_tab_widget):
"""Callback on any tab widget save.
diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py
index 7a7213fa66..4c7bf87ce8 100644
--- a/openpype/tools/settings/settings/widgets.py
+++ b/openpype/tools/settings/settings/widgets.py
@@ -11,6 +11,7 @@ from openpype.tools.utils.widgets import ImageButton
from openpype.tools.utils.lib import paint_image_with_color
from openpype.widgets.nice_checkbox import NiceCheckbox
+from openpype.tools.utils import PlaceholderLineEdit
from openpype.settings.lib import get_system_settings
from .images import (
get_pixmap,
@@ -24,7 +25,7 @@ from .constants import (
)
-class SettingsLineEdit(QtWidgets.QLineEdit):
+class SettingsLineEdit(PlaceholderLineEdit):
focused_in = QtCore.Signal()
def focusInEvent(self, event):
@@ -746,6 +747,13 @@ class ProjectListWidget(QtWidgets.QWidget):
index, QtCore.QItemSelectionModel.SelectionFlag.SelectCurrent
)
+ def get_project_names(self):
+ output = []
+ for row in range(self.project_proxy.rowCount()):
+ index = self.project_proxy.index(row, 0)
+ output.append(index.data(PROJECT_NAME_ROLE))
+ return output
+
def refresh(self):
selected_project = None
for index in self.project_list.selectedIndexes():
diff --git a/openpype/tools/settings/settings/window.py b/openpype/tools/settings/settings/window.py
index fd0cd1d7cd..c376e5e91e 100644
--- a/openpype/tools/settings/settings/window.py
+++ b/openpype/tools/settings/settings/window.py
@@ -63,7 +63,9 @@ class MainWidget(QtWidgets.QWidget):
tab_widget.restart_required_trigger.connect(
self._on_restart_required
)
+ tab_widget.full_path_requested.connect(self._on_full_path_request)
+ self._header_tab_widget = header_tab_widget
self.tab_widgets = tab_widgets
def _on_tab_save(self, source_widget):
@@ -90,6 +92,14 @@ class MainWidget(QtWidgets.QWidget):
if app:
app.processEvents()
+ def _on_full_path_request(self, category, path):
+ for tab_widget in self.tab_widgets:
+ if tab_widget.contain_category_key(category):
+ idx = self._header_tab_widget.indexOf(tab_widget)
+ self._header_tab_widget.setCurrentIndex(idx)
+ tab_widget.set_category_path(category, path)
+ break
+
def showEvent(self, event):
super(MainWidget, self).showEvent(event)
if self._reset_on_show:
diff --git a/openpype/tools/standalonepublish/widgets/widget_asset.py b/openpype/tools/standalonepublish/widgets/widget_asset.py
index eb22883c11..f4a4dfe0c4 100644
--- a/openpype/tools/standalonepublish/widgets/widget_asset.py
+++ b/openpype/tools/standalonepublish/widgets/widget_asset.py
@@ -1,8 +1,12 @@
import contextlib
from Qt import QtWidgets, QtCore
-from . import RecursiveSortFilterProxyModel, AssetModel
+
+from openpype.tools.utils import PlaceholderLineEdit
+
from avalon.vendor import qtawesome
from avalon import style
+
+from . import RecursiveSortFilterProxyModel, AssetModel
from . import TasksTemplateModel, DeselectableTreeView
from . import _iter_model_rows
@@ -165,7 +169,7 @@ class AssetWidget(QtWidgets.QWidget):
refresh = QtWidgets.QPushButton(icon, "")
refresh.setToolTip("Refresh items")
- filter = QtWidgets.QLineEdit()
+ filter = PlaceholderLineEdit()
filter.textChanged.connect(proxy.setFilterFixedString)
filter.setPlaceholderText("Filter assets..")
diff --git a/openpype/tools/standalonepublish/widgets/widget_family.py b/openpype/tools/standalonepublish/widgets/widget_family.py
index 682a6fc974..1e20028392 100644
--- a/openpype/tools/standalonepublish/widgets/widget_family.py
+++ b/openpype/tools/standalonepublish/widgets/widget_family.py
@@ -10,7 +10,7 @@ from openpype.api import (
Creator
)
from openpype.lib import TaskNotSetError
-from avalon.tools.creator.app import SubsetAllowedSymbols
+from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS
class FamilyWidget(QtWidgets.QWidget):
@@ -223,7 +223,7 @@ class FamilyWidget(QtWidgets.QWidget):
# QUESTION should Creator care about this and here should be
# only validated with schema regex?
subset_name = re.sub(
- "[^{}]+".format(SubsetAllowedSymbols),
+ "[^{}]+".format(SUBSET_NAME_ALLOWED_SYMBOLS),
"",
subset_name
)
diff --git a/openpype/tools/subsetmanager/window.py b/openpype/tools/subsetmanager/window.py
index cb0e3c1c1e..b7430d0626 100644
--- a/openpype/tools/subsetmanager/window.py
+++ b/openpype/tools/subsetmanager/window.py
@@ -7,6 +7,7 @@ from avalon import api
from avalon.vendor import qtawesome
from openpype import style
+from openpype.tools.utils import PlaceholderLineEdit
from openpype.tools.utils.lib import (
iter_model_rows,
qt_app_context
@@ -44,7 +45,7 @@ class SubsetManagerWindow(QtWidgets.QDialog):
header_widget = QtWidgets.QWidget(left_side_widget)
# Filter input
- filter_input = QtWidgets.QLineEdit(header_widget)
+ filter_input = PlaceholderLineEdit(header_widget)
filter_input.setPlaceholderText("Filter subsets..")
# Refresh button
diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py
index 0f817d7130..8c6a6d3266 100644
--- a/openpype/tools/tray/pype_tray.py
+++ b/openpype/tools/tray/pype_tray.py
@@ -370,8 +370,12 @@ class PypeTrayStarter(QtCore.QObject):
splash = self._get_splash()
splash.show()
self._tray_widget.show()
+ # Make sure tray and splash are painted out
+ QtWidgets.QApplication.processEvents()
elif self._timer_counter == 1:
+ # Second processing of events to make sure splash is painted
+ QtWidgets.QApplication.processEvents()
self._timer_counter += 1
self._tray_widget.initialize_modules()
diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py
index e69de29bb2..7f15e64767 100644
--- a/openpype/tools/utils/__init__.py
+++ b/openpype/tools/utils/__init__.py
@@ -0,0 +1,8 @@
+from .widgets import (
+ PlaceholderLineEdit,
+)
+
+
+__all__ = (
+ "PlaceholderLineEdit",
+)
diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py
index ef1cd3cf5c..60c9e79829 100644
--- a/openpype/tools/utils/host_tools.py
+++ b/openpype/tools/utils/host_tools.py
@@ -5,6 +5,7 @@ use singleton approach with global functions (using helper anyway).
"""
import avalon.api
+from .lib import qt_app_context
class HostToolsHelper:
@@ -61,22 +62,23 @@ class HostToolsHelper:
if save is None:
save = True
- workfiles_tool = self.get_workfiles_tool(parent)
- workfiles_tool.set_save_enabled(save)
+ with qt_app_context():
+ workfiles_tool = self.get_workfiles_tool(parent)
+ workfiles_tool.set_save_enabled(save)
- if not workfiles_tool.isVisible():
- workfiles_tool.show()
+ if not workfiles_tool.isVisible():
+ workfiles_tool.show()
- if use_context:
- context = {
- "asset": avalon.api.Session["AVALON_ASSET"],
- "task": avalon.api.Session["AVALON_TASK"]
- }
- workfiles_tool.set_context(context)
+ if use_context:
+ context = {
+ "asset": avalon.api.Session["AVALON_ASSET"],
+ "task": avalon.api.Session["AVALON_TASK"]
+ }
+ workfiles_tool.set_context(context)
- # Pull window to the front.
- workfiles_tool.raise_()
- workfiles_tool.activateWindow()
+ # Pull window to the front.
+ workfiles_tool.raise_()
+ workfiles_tool.activateWindow()
def get_loader_tool(self, parent):
"""Create, cache and return loader tool window."""
@@ -90,20 +92,21 @@ class HostToolsHelper:
def show_loader(self, parent=None, use_context=None):
"""Loader tool for loading representations."""
- loader_tool = self.get_loader_tool(parent)
+ with qt_app_context():
+ loader_tool = self.get_loader_tool(parent)
- loader_tool.show()
- loader_tool.raise_()
- loader_tool.activateWindow()
+ loader_tool.show()
+ loader_tool.raise_()
+ loader_tool.activateWindow()
- if use_context is None:
- use_context = False
+ if use_context is None:
+ use_context = False
- if use_context:
- context = {"asset": avalon.api.Session["AVALON_ASSET"]}
- loader_tool.set_context(context, refresh=True)
- else:
- loader_tool.refresh()
+ if use_context:
+ context = {"asset": avalon.api.Session["AVALON_ASSET"]}
+ loader_tool.set_context(context, refresh=True)
+ else:
+ loader_tool.refresh()
def get_creator_tool(self, parent):
"""Create, cache and return creator tool window."""
@@ -117,13 +120,14 @@ class HostToolsHelper:
def show_creator(self, parent=None):
"""Show tool to create new instantes for publishing."""
- creator_tool = self.get_creator_tool(parent)
- creator_tool.refresh()
- creator_tool.show()
+ with qt_app_context():
+ creator_tool = self.get_creator_tool(parent)
+ creator_tool.refresh()
+ creator_tool.show()
- # Pull window to the front.
- creator_tool.raise_()
- creator_tool.activateWindow()
+ # Pull window to the front.
+ creator_tool.raise_()
+ creator_tool.activateWindow()
def get_subset_manager_tool(self, parent):
"""Create, cache and return subset manager tool window."""
@@ -139,12 +143,13 @@ class HostToolsHelper:
def show_subset_manager(self, parent=None):
"""Show tool display/remove existing created instances."""
- subset_manager_tool = self.get_subset_manager_tool(parent)
- subset_manager_tool.show()
+ with qt_app_context():
+ subset_manager_tool = self.get_subset_manager_tool(parent)
+ subset_manager_tool.show()
- # Pull window to the front.
- subset_manager_tool.raise_()
- subset_manager_tool.activateWindow()
+ # Pull window to the front.
+ subset_manager_tool.raise_()
+ subset_manager_tool.activateWindow()
def get_scene_inventory_tool(self, parent):
"""Create, cache and return scene inventory tool window."""
@@ -160,13 +165,14 @@ class HostToolsHelper:
def show_scene_inventory(self, parent=None):
"""Show tool maintain loaded containers."""
- scene_inventory_tool = self.get_scene_inventory_tool(parent)
- scene_inventory_tool.show()
- scene_inventory_tool.refresh()
+ with qt_app_context():
+ scene_inventory_tool = self.get_scene_inventory_tool(parent)
+ scene_inventory_tool.show()
+ scene_inventory_tool.refresh()
- # Pull window to the front.
- scene_inventory_tool.raise_()
- scene_inventory_tool.activateWindow()
+ # Pull window to the front.
+ scene_inventory_tool.raise_()
+ scene_inventory_tool.activateWindow()
def get_library_loader_tool(self, parent):
"""Create, cache and return library loader tool window."""
@@ -182,11 +188,12 @@ class HostToolsHelper:
def show_library_loader(self, parent=None):
"""Loader tool for loading representations from library project."""
- library_loader_tool = self.get_library_loader_tool(parent)
- library_loader_tool.show()
- library_loader_tool.raise_()
- library_loader_tool.activateWindow()
- library_loader_tool.refresh()
+ with qt_app_context():
+ library_loader_tool = self.get_library_loader_tool(parent)
+ library_loader_tool.show()
+ library_loader_tool.raise_()
+ library_loader_tool.activateWindow()
+ library_loader_tool.refresh()
def show_publish(self, parent=None):
"""Publish UI."""
@@ -207,9 +214,10 @@ class HostToolsHelper:
"""Look manager is Maya specific tool for look management."""
from avalon import style
- look_assigner_tool = self.get_look_assigner_tool(parent)
- look_assigner_tool.show()
- look_assigner_tool.setStyleSheet(style.load_stylesheet())
+ with qt_app_context():
+ look_assigner_tool = self.get_look_assigner_tool(parent)
+ look_assigner_tool.show()
+ look_assigner_tool.setStyleSheet(style.load_stylesheet())
def get_experimental_tools_dialog(self, parent=None):
"""Dialog of experimental tools.
@@ -232,11 +240,12 @@ class HostToolsHelper:
def show_experimental_tools_dialog(self, parent=None):
"""Show dialog with experimental tools."""
- dialog = self.get_experimental_tools_dialog(parent)
+ with qt_app_context():
+ dialog = self.get_experimental_tools_dialog(parent)
- dialog.show()
- dialog.raise_()
- dialog.activateWindow()
+ dialog.show()
+ dialog.raise_()
+ dialog.activateWindow()
def get_tool_by_name(self, tool_name, parent=None, *args, **kwargs):
"""Show tool by it's name.
diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py
index 009c1dc506..3bfa092a21 100644
--- a/openpype/tools/utils/widgets.py
+++ b/openpype/tools/utils/widgets.py
@@ -12,22 +12,17 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit):
"""Set placeholder color of QLineEdit in Qt 5.12 and higher."""
def __init__(self, *args, **kwargs):
super(PlaceholderLineEdit, self).__init__(*args, **kwargs)
- self._first_show = True
-
- def showEvent(self, event):
- super(PlaceholderLineEdit, self).showEvent(event)
- if self._first_show:
- self._first_show = False
+ # Change placeholder palette color
+ if hasattr(QtGui.QPalette, "PlaceholderText"):
filter_palette = self.palette()
- if hasattr(filter_palette, "PlaceholderText"):
- color_obj = get_objected_colors()["font"]
- color = color_obj.get_qcolor()
- color.setAlpha(67)
- filter_palette.setColor(
- filter_palette.PlaceholderText,
- color
- )
- self.setPalette(filter_palette)
+ color_obj = get_objected_colors()["font"]
+ color = color_obj.get_qcolor()
+ color.setAlpha(67)
+ filter_palette.setColor(
+ QtGui.QPalette.PlaceholderText,
+ color
+ )
+ self.setPalette(filter_palette)
class ImageButton(QtWidgets.QPushButton):
diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py
index a4b1717a1c..7973b88b82 100644
--- a/openpype/tools/workfiles/app.py
+++ b/openpype/tools/workfiles/app.py
@@ -15,6 +15,7 @@ from openpype.tools.utils.lib import (
schedule,
qt_app_context
)
+from openpype.tools.utils import PlaceholderLineEdit
from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget
from openpype.tools.utils.tasks_widget import TasksWidget
from openpype.tools.utils.delegates import PrettyTimeDelegate
@@ -68,12 +69,16 @@ class NameWindow(QtWidgets.QDialog):
"config.tasks": True,
}
)
+
asset_doc = io.find_one(
{
"type": "asset",
"name": asset_name
},
- {"data.tasks": True}
+ {
+ "data.tasks": True,
+ "data.parents": True
+ }
)
task_type = asset_doc["data"]["tasks"].get(task_name, {}).get("type")
@@ -81,6 +86,11 @@ class NameWindow(QtWidgets.QDialog):
project_task_types = project_doc["config"]["tasks"]
task_short = project_task_types.get(task_type, {}).get("short_name")
+ asset_parents = asset_doc["data"]["parents"]
+ parent_name = project_doc["name"]
+ if asset_parents:
+ parent_name = asset_parents[-1]
+
self.data = {
"project": {
"name": project_doc["name"],
@@ -92,6 +102,7 @@ class NameWindow(QtWidgets.QDialog):
"type": task_type,
"short": task_short,
},
+ "parent": parent_name,
"version": 1,
"user": getpass.getuser(),
"comment": "",
@@ -139,7 +150,7 @@ class NameWindow(QtWidgets.QDialog):
preview_label = QtWidgets.QLabel("Preview filename", inputs_widget)
# Subversion input
- subversion_input = QtWidgets.QLineEdit(inputs_widget)
+ subversion_input = PlaceholderLineEdit(inputs_widget)
subversion_input.setPlaceholderText("Will be part of filename.")
# Extensions combobox
@@ -394,9 +405,9 @@ class FilesWidget(QtWidgets.QWidget):
files_view.setColumnWidth(0, 330)
# Filtering input
- filter_input = QtWidgets.QLineEdit(self)
- filter_input.textChanged.connect(proxy_model.setFilterFixedString)
+ filter_input = PlaceholderLineEdit(self)
filter_input.setPlaceholderText("Filter files..")
+ filter_input.textChanged.connect(proxy_model.setFilterFixedString)
# Home Page
# Build buttons widget for files widget
diff --git a/openpype/version.py b/openpype/version.py
index 2e9592f57d..273755dfd0 100644
--- a/openpype/version.py
+++ b/openpype/version.py
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
-__version__ = "3.7.0-nightly.3"
+__version__ = "3.7.0-nightly.10"
diff --git a/poetry.lock b/poetry.lock
index c07a20253c..f513b76611 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -219,16 +219,16 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "clique"
-version = "1.5.0"
+version = "1.6.1"
description = "Manage collections with common numerical component"
category = "main"
optional = false
-python-versions = "*"
+python-versions = ">=2.7, <4.0"
[package.extras]
-dev = ["lowdown (>=0.1.0,<1)", "pytest (>=2.3.5,<3)", "pytest-cov (>=2,<3)", "pytest-runner (>=2.7,<3)", "sphinx (>=1.2.2,<2)", "sphinx-rtd-theme (>=0.1.6,<1)"]
-doc = ["lowdown (>=0.1.0,<1)", "sphinx (>=1.2.2,<2)", "sphinx-rtd-theme (>=0.1.6,<1)"]
-test = ["pytest (>=2.3.5,<3)", "pytest-cov (>=2,<3)", "pytest-runner (>=2.7,<3)"]
+dev = ["sphinx (>=2,<4)", "sphinx-rtd-theme (>=0.1.6,<1)", "lowdown (>=0.2.0,<1)", "pytest-runner (>=2.7,<3)", "pytest (>=2.3.5,<5)", "pytest-cov (>=2,<3)"]
+doc = ["sphinx (>=2,<4)", "sphinx-rtd-theme (>=0.1.6,<1)", "lowdown (>=0.2.0,<1)"]
+test = ["pytest-runner (>=2.7,<3)", "pytest (>=2.3.5,<5)", "pytest-cov (>=2,<3)"]
[[package]]
name = "colorama"
@@ -1580,7 +1580,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes
[metadata]
lock-version = "1.1"
python-versions = "3.7.*"
-content-hash = "fb6db80d126fe7ef2d1d06d0381b6d11445d6d3e54b33585f6b0a0b6b0b9d372"
+content-hash = "877c1c6292735f495d915fc6aa85450eb20fc63f266a9c6bf7ba1125af3579a5"
[metadata.files]
acre = []
@@ -1749,8 +1749,8 @@ click = [
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
]
clique = [
- {file = "clique-1.5.0-py2-none-any.whl", hash = "sha256:77efbf5d99a398a50ca4591373def45c9c70fb43232cdc32f521cf5257ce4330"},
- {file = "clique-1.5.0.tar.gz", hash = "sha256:c34a4eac30187a5b7d75bc8cf600ddc50ceef50a423772a4c96f1dc8440af5fa"},
+ {file = "clique-1.6.1-py2.py3-none-any.whl", hash = "sha256:8619774fa035661928dd8c93cd805acf2d42533ccea1b536c09815ed426c9858"},
+ {file = "clique-1.6.1.tar.gz", hash = "sha256:90165c1cf162d4dd1baef83ceaa1afc886b453e379094fa5b60ea470d1733e66"},
]
colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
@@ -2180,9 +2180,13 @@ protobuf = [
{file = "protobuf-3.17.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2ae692bb6d1992afb6b74348e7bb648a75bb0d3565a3f5eea5bec8f62bd06d87"},
{file = "protobuf-3.17.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:99938f2a2d7ca6563c0ade0c5ca8982264c484fdecf418bd68e880a7ab5730b1"},
{file = "protobuf-3.17.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6902a1e4b7a319ec611a7345ff81b6b004b36b0d2196ce7a748b3493da3d226d"},
+ {file = "protobuf-3.17.3-cp38-cp38-win32.whl", hash = "sha256:59e5cf6b737c3a376932fbfb869043415f7c16a0cf176ab30a5bbc419cd709c1"},
+ {file = "protobuf-3.17.3-cp38-cp38-win_amd64.whl", hash = "sha256:ebcb546f10069b56dc2e3da35e003a02076aaa377caf8530fe9789570984a8d2"},
{file = "protobuf-3.17.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4ffbd23640bb7403574f7aff8368e2aeb2ec9a5c6306580be48ac59a6bac8bde"},
{file = "protobuf-3.17.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:26010f693b675ff5a1d0e1bdb17689b8b716a18709113288fead438703d45539"},
{file = "protobuf-3.17.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e76d9686e088fece2450dbc7ee905f9be904e427341d289acbe9ad00b78ebd47"},
+ {file = "protobuf-3.17.3-cp39-cp39-win32.whl", hash = "sha256:a38bac25f51c93e4be4092c88b2568b9f407c27217d3dd23c7a57fa522a17554"},
+ {file = "protobuf-3.17.3-cp39-cp39-win_amd64.whl", hash = "sha256:85d6303e4adade2827e43c2b54114d9a6ea547b671cb63fafd5011dc47d0e13d"},
{file = "protobuf-3.17.3-py2.py3-none-any.whl", hash = "sha256:2bfb815216a9cd9faec52b16fd2bfa68437a44b67c56bee59bc3926522ecb04e"},
{file = "protobuf-3.17.3.tar.gz", hash = "sha256:72804ea5eaa9c22a090d2803813e280fb273b62d5ae497aaf3553d141c4fdd7b"},
]
diff --git a/pyproject.toml b/pyproject.toml
index ac1d133561..ea6d9ee5e5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "OpenPype"
-version = "3.7.0-nightly.3" # OpenPype
+version = "3.7.0-nightly.10" # OpenPype
description = "Open VFX and Animation pipeline with support."
authors = ["OpenPype Team "]
license = "MIT License"
@@ -36,7 +36,7 @@ opentimelineio = { version = "0.14.0.dev1", source = "openpype" }
appdirs = "^1.4.3"
blessed = "^1.17" # openpype terminal formatting
coolname = "*"
-clique = "1.5.*"
+clique = "1.6.*"
Click = "^7"
dnspython = "^2.1.0"
ftrack-python-api = "2.0.*"
diff --git a/repos/avalon-core b/repos/avalon-core
index 9499f6517a..ffe9e910f1 160000
--- a/repos/avalon-core
+++ b/repos/avalon-core
@@ -1 +1 @@
-Subproject commit 9499f6517a1ff2d3bf94c5d34c0aece146734760
+Subproject commit ffe9e910f1f382e222d457d8e4a8426c41ed43ae
diff --git a/setup.py b/setup.py
index cd3ed4f82c..a21645e66a 100644
--- a/setup.py
+++ b/setup.py
@@ -48,7 +48,8 @@ install_requires = [
"filecmp",
"dns",
# Python defaults (cx_Freeze skip them by default)
- "dbm"
+ "dbm",
+ "sqlite3"
]
includes = []
diff --git a/start.py b/start.py
index 0f7e82071d..cc6cae547e 100644
--- a/start.py
+++ b/start.py
@@ -339,13 +339,14 @@ def set_avalon_environments():
os.environ.get("AVALON_MONGO")
or os.environ["OPENPYPE_MONGO"]
)
+ avalon_db = os.environ.get("AVALON_DB") or "avalon" # for tests
os.environ.update({
# Mongo url (use same as OpenPype has)
"AVALON_MONGO": avalon_mongo_url,
"AVALON_SCHEMA": schema_path,
# Mongo DB name where avalon docs are stored
- "AVALON_DB": "avalon",
+ "AVALON_DB": avalon_db,
# Name of config
"AVALON_CONFIG": "openpype",
"AVALON_LABEL": "OpenPype"
@@ -925,7 +926,9 @@ def boot():
sys.exit(1)
os.environ["OPENPYPE_MONGO"] = openpype_mongo
- os.environ["OPENPYPE_DATABASE_NAME"] = "openpype" # name of Pype database
+ # name of Pype database
+ os.environ["OPENPYPE_DATABASE_NAME"] = \
+ os.environ.get("OPENPYPE_DATABASE_NAME") or "openpype"
_print(">>> run disk mapping command ...")
run_disk_mapping_commands(openpype_mongo)
@@ -1107,15 +1110,15 @@ def get_info(use_staging=None) -> list:
# Reinitialize
PypeLogger.initialize()
- log_components = PypeLogger.log_mongo_url_components
- if log_components["host"]:
- inf.append(("Logging to MongoDB", log_components["host"]))
- inf.append((" - port", log_components["port"] or ""))
+ mongo_components = get_default_components()
+ if mongo_components["host"]:
+ inf.append(("Logging to MongoDB", mongo_components["host"]))
+ inf.append((" - port", mongo_components["port"] or ""))
inf.append((" - database", PypeLogger.log_database_name))
inf.append((" - collection", PypeLogger.log_collection_name))
- inf.append((" - user", log_components["username"] or ""))
- if log_components["auth_db"]:
- inf.append((" - auth source", log_components["auth_db"]))
+ inf.append((" - user", mongo_components["username"] or ""))
+ if mongo_components["auth_db"]:
+ inf.append((" - auth source", mongo_components["auth_db"]))
maximum = max(len(i[0]) for i in inf)
formatted = []
diff --git a/tests/README.md b/tests/README.md
index 6317b2ab3c..d0578f8059 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -14,12 +14,12 @@ How to run:
----------
- single test class could be run by PyCharm and its pytest runner directly
- OR
-- use Openpype command 'runtests' from command line
--- `${OPENPYPE_ROOT}/start.py runtests`
+- use Openpype command 'runtests' from command line (`.venv` in ${OPENPYPE_ROOT} must be activated to use configured Python!)
+-- `${OPENPYPE_ROOT}/python start.py runtests`
By default, this command will run all tests in ${OPENPYPE_ROOT}/tests.
Specific location could be provided to this command as an argument, either as absolute path, or relative path to ${OPENPYPE_ROOT}.
-(eg. `${OPENPYPE_ROOT}/start.py runtests ../tests/integration`) will trigger only tests in `integration` folder.
+(eg. `${OPENPYPE_ROOT}/python start.py runtests ../tests/integration`) will trigger only tests in `integration` folder.
See `${OPENPYPE_ROOT}/cli.py:runtests` for other arguments.
diff --git a/tests/integration/README.md b/tests/integration/README.md
index 81c07ec50c..0b6a1804ae 100644
--- a/tests/integration/README.md
+++ b/tests/integration/README.md
@@ -5,33 +5,64 @@ Contains end-to-end tests for automatic testing of OP.
Should run headless publish on all hosts to check basic publish use cases automatically
to limit regression issues.
+How to run
+----------
+- activate `{OPENPYPE_ROOT}/.venv`
+- run in cmd
+`{OPENPYPE_ROOT}/.venv/Scripts/python.exe {OPENPYPE_ROOT}/start.py runtests {OPENPYPE_ROOT}/tests/integration`
+ - add `hosts/APP_NAME` after integration part to limit only on specific app (eg. `{OPENPYPE_ROOT}/tests/integration/hosts/maya`)
+
+OR can use built executables
+`openpype_console runtests {ABS_PATH}/tests/integration`
+
+How to check logs/errors from app
+--------------------------------
+Keep PERSIST to True in the class and check `test_openpype.logs` collection.
+
How to create test for publishing from host
------------------------------------------
-- Extend PublishTest
+- Extend PublishTest in `tests/lib/testing_classes.py`
- Use `resources\test_data.zip` skeleton file as a template for testing input data
- Put workfile into `test_data.zip/input/workfile`
- If you require other than base DB dumps provide them to `test_data.zip/input/dumps`
-- (Check commented code in `db_handler.py` how to dump specific DB. Currently all collections will be dumped.)
- Implement `last_workfile_path`
- `startup_scripts` - must contain pointing host to startup script saved into `test_data.zip/input/startup`
- -- Script must contain something like
+ -- Script must contain something like (pseudocode)
```
import openpype
from avalon import api, HOST
+
+from openpype.api import Logger
+
+log = Logger().get_logger(__name__)
api.install(HOST)
-pyblish.util.publish()
+log_lines = []
+for result in pyblish.util.publish_iter():
+ for record in result["records"]: # for logging to test_openpype DB
+ log_lines.append("{}: {}".format(
+ result["plugin"].label, record.msg))
+
+ if result["error"]:
+ err_fmt = "Failed {plugin.__name__}: {error} -- {error.traceback}"
+ log.error(err_fmt.format(**result))
EXIT_APP (command to exit host)
```
(Install and publish methods must be triggered only AFTER host app is fully initialized!)
-- Zip `test_data.zip`, named it with descriptive name, upload it to Google Drive, right click - `Get link`, copy hash id
+- If you would like add any command line arguments for your host app add it to `test_data.zip/input/app_args/app_args.json` (as a json list)
+- Provide any required environment variables to `test_data.zip/input/env_vars/env_vars.json` (as a json dictionary)
+- Zip `test_data.zip`, named it with descriptive name, upload it to Google Drive, right click - `Get link`, copy hash id (file must be accessible to anyone with a link!)
- Put this hash id and zip file name into TEST_FILES [(HASH_ID, FILE_NAME, MD5_OPTIONAL)]. If you want to check MD5 of downloaded
file, provide md5 value of zipped file.
- Implement any assert checks you need in extended class
- Run test class manually (via Pycharm or pytest runner (TODO))
-- If you want test to compare expected files to published one, set PERSIST to True, run test manually
+- If you want test to visually compare expected files to published one, set PERSIST to True, run test manually
-- Locate temporary `publish` subfolder of temporary folder (found in debugging console log)
-- Copy whole folder content into .zip file into `expected` subfolder
-- By default tests are comparing only structure of `expected` and published format (eg. if you want to save space, replace published files with empty files, but with expected names!)
- -- Zip and upload again, change PERSIST to False
\ No newline at end of file
+ -- Zip and upload again, change PERSIST to False
+
+- Use `TEST_DATA_FOLDER` variable in your class to reuse existing downloaded and unzipped test data (for faster creation of tests)
+- Keep `APP_VARIANT` empty if you want to trigger test on latest version of app, or provide explicit value (as '2022' for Photoshop for example)
\ No newline at end of file
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
new file mode 100644
index 0000000000..400c0dcc2a
--- /dev/null
+++ b/tests/integration/conftest.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+# adds command line arguments for 'runtests' as a fixtures
+import pytest
+
+
+def pytest_addoption(parser):
+ parser.addoption(
+ "--test_data_folder", action="store", default=None,
+ help="Provide url of a folder of unzipped test file"
+ )
+
+ parser.addoption(
+ "--persist", action="store", default=None,
+ help="True - keep test_db, test_openpype, outputted test files"
+ )
+
+ parser.addoption(
+ "--app_variant", action="store", default=None,
+ help="Keep empty to locate latest installed variant or explicit"
+ )
+
+
+@pytest.fixture(scope="module")
+def test_data_folder(request):
+ return request.config.getoption("--test_data_folder")
+
+
+@pytest.fixture(scope="module")
+def persist(request):
+ return request.config.getoption("--persist")
+
+
+@pytest.fixture(scope="module")
+def app_variant(request):
+ return request.config.getoption("--app_variant")
diff --git a/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py b/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py
new file mode 100644
index 0000000000..407c4f8a3a
--- /dev/null
+++ b/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py
@@ -0,0 +1,102 @@
+import pytest
+import os
+import shutil
+
+from tests.lib.testing_classes import PublishTest
+
+
+class TestPublishInAfterEffects(PublishTest):
+ """Basic test case for publishing in AfterEffects
+
+ Uses generic TestCase to prepare fixtures for test data, testing DBs,
+ env vars.
+
+ Opens AfterEffects, run publish on prepared workile.
+
+ Test zip file sets 3 required env vars:
+ - HEADLESS_PUBLISH - this triggers publish immediately app is open
+ - IS_TEST - this differentiate between regular webpublish
+ - PYBLISH_TARGETS
+
+ Then checks content of DB (if subset, version, representations were
+ created.
+ Checks tmp folder if all expected files were published.
+
+ """
+ PERSIST = True
+
+ TEST_FILES = [
+ ("1c8261CmHwyMgS-g7S4xL5epAp0jCBmhf",
+ "test_aftereffects_publish.zip",
+ "")
+ ]
+
+ APP = "aftereffects"
+ APP_VARIANT = "2022"
+
+ APP_NAME = "{}/{}".format(APP, APP_VARIANT)
+
+ TIMEOUT = 120 # publish timeout
+
+ @pytest.fixture(scope="module")
+ def last_workfile_path(self, download_test_data):
+ """Get last_workfile_path from source data.
+
+ Maya expects workfile in proper folder, so copy is done first.
+ """
+ src_path = os.path.join(download_test_data,
+ "input",
+ "workfile",
+ "test_project_test_asset_TestTask_v001.aep")
+ dest_folder = os.path.join(download_test_data,
+ self.PROJECT,
+ self.ASSET,
+ "work",
+ self.TASK)
+ os.makedirs(dest_folder)
+ dest_path = os.path.join(dest_folder,
+ "test_project_test_asset_TestTask_v001.aep")
+ shutil.copy(src_path, dest_path)
+
+ yield dest_path
+
+ @pytest.fixture(scope="module")
+ def startup_scripts(self, monkeypatch_session, download_test_data):
+ """Points AfterEffects to userSetup file from input data"""
+ pass
+
+ def test_db_asserts(self, dbcon, publish_finished):
+ """Host and input data dependent expected results in DB."""
+ print("test_db_asserts")
+
+ assert 2 == dbcon.count_documents({"type": "version"}), \
+ "Not expected no of versions"
+
+ assert 0 == dbcon.count_documents({"type": "version",
+ "name": {"$ne": 1}}), \
+ "Only versions with 1 expected"
+
+ assert 1 == dbcon.count_documents({"type": "subset",
+ "name": "imageMainBackgroundcopy"
+ }), \
+ "modelMain subset must be present"
+
+ assert 1 == dbcon.count_documents({"type": "subset",
+ "name": "workfileTest_task"}), \
+ "workfileTesttask subset must be present"
+
+ assert 1 == dbcon.count_documents({"type": "subset",
+ "name": "reviewTesttask"}), \
+ "reviewTesttask subset must be present"
+
+ assert 4 == dbcon.count_documents({"type": "representation"}), \
+ "Not expected no of representations"
+
+ assert 1 == dbcon.count_documents({"type": "representation",
+ "context.subset": "renderTestTaskDefault", # noqa E501
+ "context.ext": "png"}), \
+ "Not expected no of representations with ext 'png'"
+
+
+if __name__ == "__main__":
+ test_case = TestPublishInAfterEffects()
diff --git a/tests/integration/hosts/maya/lib.py b/tests/integration/hosts/maya/lib.py
new file mode 100644
index 0000000000..f3a438c065
--- /dev/null
+++ b/tests/integration/hosts/maya/lib.py
@@ -0,0 +1,41 @@
+import os
+import pytest
+import shutil
+
+from tests.lib.testing_classes import HostFixtures
+
+
+class MayaTestClass(HostFixtures):
+ @pytest.fixture(scope="module")
+ def last_workfile_path(self, download_test_data, output_folder_url):
+ """Get last_workfile_path from source data.
+
+ Maya expects workfile in proper folder, so copy is done first.
+ """
+ src_path = os.path.join(download_test_data,
+ "input",
+ "workfile",
+ "test_project_test_asset_TestTask_v001.mb")
+ dest_folder = os.path.join(output_folder_url,
+ self.PROJECT,
+ self.ASSET,
+ "work",
+ self.TASK)
+ os.makedirs(dest_folder)
+ dest_path = os.path.join(dest_folder,
+ "test_project_test_asset_TestTask_v001.mb")
+ shutil.copy(src_path, dest_path)
+
+ yield dest_path
+
+ @pytest.fixture(scope="module")
+ def startup_scripts(self, monkeypatch_session, download_test_data):
+ """Points Maya to userSetup file from input data"""
+ startup_path = os.path.join(download_test_data,
+ "input",
+ "startup")
+ original_pythonpath = os.environ.get("PYTHONPATH")
+ monkeypatch_session.setenv("PYTHONPATH",
+ "{}{}{}".format(startup_path,
+ os.pathsep,
+ original_pythonpath))
diff --git a/tests/integration/hosts/maya/test_publish_in_maya.py b/tests/integration/hosts/maya/test_publish_in_maya.py
index 1babf30029..68b0564428 100644
--- a/tests/integration/hosts/maya/test_publish_in_maya.py
+++ b/tests/integration/hosts/maya/test_publish_in_maya.py
@@ -1,11 +1,7 @@
-import pytest
-import os
-import shutil
-
-from tests.lib.testing_classes import PublishTest
+from tests.integration.hosts.maya.lib import MayaTestClass
-class TestPublishInMaya(PublishTest):
+class TestPublishInMaya(MayaTestClass):
"""Basic test case for publishing in Maya
Shouldnt be running standalone only via 'runtests' pype command! (??)
@@ -13,60 +9,31 @@ class TestPublishInMaya(PublishTest):
Uses generic TestCase to prepare fixtures for test data, testing DBs,
env vars.
- Opens Maya, run publish on prepared workile.
+ Always pulls and uses test data from GDrive!
+
+ Opens Maya, runs publish on prepared workile.
Then checks content of DB (if subset, version, representations were
created.
Checks tmp folder if all expected files were published.
+ How to run:
+ (in cmd with activated {OPENPYPE_ROOT}/.venv)
+ {OPENPYPE_ROOT}/.venv/Scripts/python.exe {OPENPYPE_ROOT}/start.py runtests ../tests/integration/hosts/maya # noqa: E501
+
"""
- PERSIST = True
+ PERSIST = False
TEST_FILES = [
- ("1pOwjA_VVBc6ooTZyFxtAwLS2KZHaBlkY", "test_maya_publish.zip", "")
+ ("1BTSIIULJTuDc8VvXseuiJV_fL6-Bu7FP", "test_maya_publish.zip", "")
]
APP = "maya"
- APP_VARIANT = "2019"
-
- APP_NAME = "{}/{}".format(APP, APP_VARIANT)
+ # keep empty to locate latest installed variant or explicit
+ APP_VARIANT = ""
TIMEOUT = 120 # publish timeout
- @pytest.fixture(scope="module")
- def last_workfile_path(self, download_test_data):
- """Get last_workfile_path from source data.
-
- Maya expects workfile in proper folder, so copy is done first.
- """
- src_path = os.path.join(download_test_data,
- "input",
- "workfile",
- "test_project_test_asset_TestTask_v001.mb")
- dest_folder = os.path.join(download_test_data,
- self.PROJECT,
- self.ASSET,
- "work",
- self.TASK)
- os.makedirs(dest_folder)
- dest_path = os.path.join(dest_folder,
- "test_project_test_asset_TestTask_v001.mb")
- shutil.copy(src_path, dest_path)
-
- yield dest_path
-
- @pytest.fixture(scope="module")
- def startup_scripts(self, monkeypatch_session, download_test_data):
- """Points Maya to userSetup file from input data"""
- startup_path = os.path.join(download_test_data,
- "input",
- "startup")
- original_pythonpath = os.environ.get("PYTHONPATH")
- monkeypatch_session.setenv("PYTHONPATH",
- "{}{}{}".format(startup_path,
- os.pathsep,
- original_pythonpath))
-
def test_db_asserts(self, dbcon, publish_finished):
"""Host and input data dependent expected results in DB."""
print("test_db_asserts")
diff --git a/tests/integration/hosts/nuke/lib.py b/tests/integration/hosts/nuke/lib.py
new file mode 100644
index 0000000000..d3c3d7ba81
--- /dev/null
+++ b/tests/integration/hosts/nuke/lib.py
@@ -0,0 +1,44 @@
+import os
+import pytest
+import shutil
+
+from tests.lib.testing_classes import HostFixtures
+
+
+class NukeTestClass(HostFixtures):
+ @pytest.fixture(scope="module")
+ def last_workfile_path(self, download_test_data, output_folder_url):
+ """Get last_workfile_path from source data.
+
+ """
+ source_file_name = "test_project_test_asset_CompositingInNuke_v001.nk"
+ src_path = os.path.join(download_test_data,
+ "input",
+ "workfile",
+ source_file_name)
+ dest_folder = os.path.join(output_folder_url,
+ self.PROJECT,
+ self.ASSET,
+ "work",
+ self.TASK)
+ if not os.path.exists(dest_folder):
+ os.makedirs(dest_folder)
+
+ dest_path = os.path.join(dest_folder,
+ source_file_name)
+
+ shutil.copy(src_path, dest_path)
+
+ yield dest_path
+
+ @pytest.fixture(scope="module")
+ def startup_scripts(self, monkeypatch_session, download_test_data):
+ """Points Nuke to userSetup file from input data"""
+ startup_path = os.path.join(download_test_data,
+ "input",
+ "startup")
+ original_nuke_path = os.environ.get("NUKE_PATH", "")
+ monkeypatch_session.setenv("NUKE_PATH",
+ "{}{}{}".format(startup_path,
+ os.pathsep,
+ original_nuke_path))
\ No newline at end of file
diff --git a/tests/integration/hosts/nuke/test_publish_in_nuke.py b/tests/integration/hosts/nuke/test_publish_in_nuke.py
new file mode 100644
index 0000000000..884160e0b5
--- /dev/null
+++ b/tests/integration/hosts/nuke/test_publish_in_nuke.py
@@ -0,0 +1,74 @@
+import logging
+
+from tests.lib.assert_classes import DBAssert
+from tests.integration.hosts.nuke.lib import NukeTestClass
+
+log = logging.getLogger("test_publish_in_nuke")
+
+
+class TestPublishInNuke(NukeTestClass):
+ """Basic test case for publishing in Nuke
+
+ Uses generic TestCase to prepare fixtures for test data, testing DBs,
+ env vars.
+
+ Opens Nuke, run publish on prepared workile.
+
+ Then checks content of DB (if subset, version, representations were
+ created.
+ Checks tmp folder if all expected files were published.
+
+ How to run:
+ (in cmd with activated {OPENPYPE_ROOT}/.venv)
+ {OPENPYPE_ROOT}/.venv/Scripts/python.exe {OPENPYPE_ROOT}/start.py runtests ../tests/integration/hosts/nuke # noqa: E501
+
+ To check log/errors from launched app's publish process keep PERSIST
+ to True and check `test_openpype.logs` collection.
+ """
+ # https://drive.google.com/file/d/1SUurHj2aiQ21ZIMJfGVBI2KjR8kIjBGI/view?usp=sharing # noqa: E501
+ TEST_FILES = [
+ ("1SUurHj2aiQ21ZIMJfGVBI2KjR8kIjBGI", "test_Nuke_publish.zip", "")
+ ]
+
+ APP = "nuke"
+
+ TIMEOUT = 120 # publish timeout
+
+ # could be overwritten by command line arguments
+ # keep empty to locate latest installed variant or explicit
+ APP_VARIANT = ""
+ PERSIST = True # True - keep test_db, test_openpype, outputted test files
+ TEST_DATA_FOLDER = None
+
+ def test_db_asserts(self, dbcon, publish_finished):
+ """Host and input data dependent expected results in DB."""
+ print("test_db_asserts")
+ failures = []
+
+ failures.append(DBAssert.count_of_types(dbcon, "version", 2))
+
+ failures.append(
+ DBAssert.count_of_types(dbcon, "version", 0, name={"$ne": 1}))
+
+ failures.append(
+ DBAssert.count_of_types(dbcon, "subset", 1,
+ name="renderCompositingInNukeMain"))
+
+ failures.append(
+ DBAssert.count_of_types(dbcon, "subset", 1,
+ name="workfileTest_task"))
+
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 4))
+
+ additional_args = {"context.subset": "renderCompositingInNukeMain",
+ "context.ext": "exr"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 1,
+ additional_args=additional_args))
+
+ assert not any(failures)
+
+
+if __name__ == "__main__":
+ test_case = TestPublishInNuke()
diff --git a/tests/integration/hosts/photoshop/lib.py b/tests/integration/hosts/photoshop/lib.py
new file mode 100644
index 0000000000..16ef2d3ae6
--- /dev/null
+++ b/tests/integration/hosts/photoshop/lib.py
@@ -0,0 +1,34 @@
+import os
+import pytest
+import shutil
+
+from tests.lib.testing_classes import HostFixtures
+
+
+class PhotoshopTestClass(HostFixtures):
+ @pytest.fixture(scope="module")
+ def last_workfile_path(self, download_test_data, output_folder_url):
+ """Get last_workfile_path from source data.
+
+ Maya expects workfile in proper folder, so copy is done first.
+ """
+ src_path = os.path.join(download_test_data,
+ "input",
+ "workfile",
+ "test_project_test_asset_TestTask_v001.psd")
+ dest_folder = os.path.join(output_folder_url,
+ self.PROJECT,
+ self.ASSET,
+ "work",
+ self.TASK)
+ os.makedirs(dest_folder)
+ dest_path = os.path.join(dest_folder,
+ "test_project_test_asset_TestTask_v001.psd")
+ shutil.copy(src_path, dest_path)
+
+ yield dest_path
+
+ @pytest.fixture(scope="module")
+ def startup_scripts(self, monkeypatch_session, download_test_data):
+ """Points Maya to userSetup file from input data"""
+ pass
diff --git a/tests/integration/hosts/photoshop/test_publish_in_photoshop.py b/tests/integration/hosts/photoshop/test_publish_in_photoshop.py
index 396468a966..32053cd9d4 100644
--- a/tests/integration/hosts/photoshop/test_publish_in_photoshop.py
+++ b/tests/integration/hosts/photoshop/test_publish_in_photoshop.py
@@ -1,67 +1,54 @@
-import pytest
-import os
-import shutil
-
-from tests.lib.testing_classes import PublishTest
+from tests.integration.hosts.photoshop.lib import PhotoshopTestClass
-class TestPublishInPhotoshop(PublishTest):
+class TestPublishInPhotoshop(PhotoshopTestClass):
"""Basic test case for publishing in Photoshop
Uses generic TestCase to prepare fixtures for test data, testing DBs,
env vars.
- Opens Maya, run publish on prepared workile.
+ Opens Photoshop, run publish on prepared workile.
+
+ Test zip file sets 3 required env vars:
+ - HEADLESS_PUBLISH - this triggers publish immediately app is open
+ - IS_TEST - this differentiate between regular webpublish
+ - PYBLISH_TARGETS
+
+ Always pulls and uses test data from GDrive!
+
+ Test zip file sets 3 required env vars:
+ - HEADLESS_PUBLISH - this triggers publish immediately app is open
+ - IS_TEST - this differentiate between regular webpublish
+ - PYBLISH_TARGETS
Then checks content of DB (if subset, version, representations were
created.
Checks tmp folder if all expected files were published.
+ How to run:
+ (in cmd with activated {OPENPYPE_ROOT}/.venv)
+ {OPENPYPE_ROOT}/.venv/Scripts/python.exe {OPENPYPE_ROOT}/start.py runtests ../tests/integration/hosts/photoshop # noqa: E501
+
"""
- PERSIST = True
+ PERSIST = False
TEST_FILES = [
- ("1Bciy2pCwMKl1UIpxuPnlX_LHMo_Xkq0K", "test_photoshop_publish.zip", "")
+ ("1zD2v5cBgkyOm_xIgKz3WKn8aFB_j8qC-", "test_photoshop_publish.zip", "")
]
APP = "photoshop"
- APP_VARIANT = "2020"
+ # keep empty to locate latest installed variant or explicit
+ APP_VARIANT = ""
APP_NAME = "{}/{}".format(APP, APP_VARIANT)
TIMEOUT = 120 # publish timeout
- @pytest.fixture(scope="module")
- def last_workfile_path(self, download_test_data):
- """Get last_workfile_path from source data.
-
- Maya expects workfile in proper folder, so copy is done first.
- """
- src_path = os.path.join(download_test_data,
- "input",
- "workfile",
- "test_project_test_asset_TestTask_v001.psd")
- dest_folder = os.path.join(download_test_data,
- self.PROJECT,
- self.ASSET,
- "work",
- self.TASK)
- os.makedirs(dest_folder)
- dest_path = os.path.join(dest_folder,
- "test_project_test_asset_TestTask_v001.psd")
- shutil.copy(src_path, dest_path)
-
- yield dest_path
-
- @pytest.fixture(scope="module")
- def startup_scripts(self, monkeypatch_session, download_test_data):
- """Points Maya to userSetup file from input data"""
- os.environ["IS_HEADLESS"] = "true"
def test_db_asserts(self, dbcon, publish_finished):
"""Host and input data dependent expected results in DB."""
print("test_db_asserts")
- assert 5 == dbcon.count_documents({"type": "version"}), \
+ assert 3 == dbcon.count_documents({"type": "version"}), \
"Not expected no of versions"
assert 0 == dbcon.count_documents({"type": "version",
@@ -69,25 +56,21 @@ class TestPublishInPhotoshop(PublishTest):
"Only versions with 1 expected"
assert 1 == dbcon.count_documents({"type": "subset",
- "name": "modelMain"}), \
+ "name": "imageMainBackgroundcopy"}
+ ), \
"modelMain subset must be present"
assert 1 == dbcon.count_documents({"type": "subset",
- "name": "workfileTest_task"}), \
+ "name": "workfileTesttask"}), \
"workfileTest_task subset must be present"
- assert 11 == dbcon.count_documents({"type": "representation"}), \
+ assert 6 == dbcon.count_documents({"type": "representation"}), \
"Not expected no of representations"
- assert 2 == dbcon.count_documents({"type": "representation",
- "context.subset": "modelMain",
- "context.ext": "abc"}), \
- "Not expected no of representations with ext 'abc'"
-
- assert 2 == dbcon.count_documents({"type": "representation",
- "context.subset": "modelMain",
- "context.ext": "ma"}), \
- "Not expected no of representations with ext 'abc'"
+ assert 1 == dbcon.count_documents({"type": "representation",
+ "context.subset": "imageMainBackgroundcopy", # noqa: E501
+ "context.ext": "png"}), \
+ "Not expected no of representations with ext 'png'"
if __name__ == "__main__":
diff --git a/tests/lib/assert_classes.py b/tests/lib/assert_classes.py
new file mode 100644
index 0000000000..7298853b67
--- /dev/null
+++ b/tests/lib/assert_classes.py
@@ -0,0 +1,45 @@
+"""Classed and methods for comparing expected and published items in DBs"""
+
+class DBAssert:
+
+ @classmethod
+ def count_of_types(cls, dbcon, queried_type, expected, **kwargs):
+ """Queries 'dbcon' and counts documents of type 'queried_type'
+
+ Args:
+ dbcon (AvalonMongoDB)
+ queried_type (str): type of document ("asset", "version"...)
+ expected (int): number of documents found
+ any number of additional keyword arguments
+
+ special handling of argument additional_args (dict)
+ with additional args like
+ {"context.subset": "XXX"}
+ """
+ args = {"type": queried_type}
+ for key, val in kwargs.items():
+ if key == "additional_args":
+ args.update(val)
+ else:
+ args[key] = val
+
+ msg = None
+ no_of_docs = dbcon.count_documents(args)
+ if expected != no_of_docs:
+ msg = "Not expected no of versions. "\
+ "Expected {}, found {}".format(expected, no_of_docs)
+
+ args.pop("type")
+ detail_str = " "
+ if args:
+ detail_str = " with {}".format(args)
+
+ status = "successful"
+ if msg:
+ status = "failed"
+
+ print("Comparing count of {}{} {}".format(queried_type,
+ detail_str,
+ status))
+
+ return msg
diff --git a/tests/lib/db_handler.py b/tests/lib/db_handler.py
index 9be70895da..b181055012 100644
--- a/tests/lib/db_handler.py
+++ b/tests/lib/db_handler.py
@@ -112,9 +112,17 @@ class DBHandler:
source 'db_name'
"""
db_name_out = db_name_out or db_name
- if self._db_exists(db_name) and not overwrite:
- raise RuntimeError("DB {} already exists".format(db_name_out) +
- "Run with overwrite=True")
+ if self._db_exists(db_name_out):
+ if not overwrite:
+ raise RuntimeError("DB {} already exists".format(db_name_out) +
+ "Run with overwrite=True")
+ else:
+ if collection:
+ coll = self.client[db_name_out].get(collection)
+ if coll:
+ coll.drop()
+ else:
+ self.teardown(db_name_out)
dir_path = os.path.join(dump_dir, db_name)
if not os.path.exists(dir_path):
@@ -136,7 +144,8 @@ class DBHandler:
print("Dropping {} database".format(db_name))
self.client.drop_database(db_name)
- def backup_to_dump(self, db_name, dump_dir, overwrite=False):
+ def backup_to_dump(self, db_name, dump_dir, overwrite=False,
+ collection=None):
"""
Helper method for running mongodump for specific 'db_name'
"""
@@ -148,7 +157,8 @@ class DBHandler:
raise RuntimeError("Backup already exists, "
"run with overwrite=True")
- query = self._dump_query(self.uri, dump_dir, db_name=db_name)
+ query = self._dump_query(self.uri, dump_dir,
+ db_name=db_name, collection=collection)
print("Mongodump query:: {}".format(query))
subprocess.run(query)
@@ -163,7 +173,7 @@ class DBHandler:
if collection:
if not db_name:
raise ValueError("db_name must be present")
- coll_part = "--nsInclude={}.{}".format(db_name, collection)
+ coll_part = "--collection={}".format(collection)
query = "\"{}\" --uri=\"{}\" --out={} {} {}".format(
"mongodump", uri, output_path, db_part, coll_part
)
@@ -187,7 +197,8 @@ class DBHandler:
drop_part = "--drop"
if db_name_out:
- db_part += " --nsTo={}.*".format(db_name_out)
+ collection_str = collection or '*'
+ db_part += " --nsTo={}.{}".format(db_name_out, collection_str)
query = "\"{}\" --uri=\"{}\" --dir=\"{}\" {} {} {}".format(
"mongorestore", uri, dump_dir, db_part, coll_part, drop_part
@@ -217,15 +228,16 @@ class DBHandler:
return query
+# Examples
# handler = DBHandler(uri="mongodb://localhost:27017")
-#
-# backup_dir = "c:\\projects\\dumps"
# #
-# handler.backup_to_dump("openpype", backup_dir, True)
-# # handler.setup_from_dump("test_db", backup_dir, True)
-# # handler.setup_from_sql_file("test_db", "c:\\projects\\sql\\item.sql",
-# # collection="test_project",
-# # drop=False, mode="upsert")
-# handler.setup_from_sql("test_db", "c:\\projects\\sql",
+# backup_dir = "c:\\projects\\test_nuke_publish\\input\\dumps"
+# # #
+# handler.backup_to_dump("avalon", backup_dir, True, collection="test_project")
+# handler.setup_from_dump("test_db", backup_dir, True, db_name_out="avalon", collection="test_project")
+# handler.setup_from_sql_file("test_db", "c:\\projects\\sql\\item.sql",
# collection="test_project",
# drop=False, mode="upsert")
+# handler.setup_from_sql("test_db", "c:\\projects\\sql",
+# collection="test_project",
+# drop=False, mode="upsert")
diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py
index 59d4abb3aa..fa467acf9c 100644
--- a/tests/lib/testing_classes.py
+++ b/tests/lib/testing_classes.py
@@ -7,10 +7,13 @@ import pytest
import tempfile
import shutil
import glob
+import platform
from tests.lib.db_handler import DBHandler
from tests.lib.file_handler import RemoteFileHandler
+from openpype.lib.remote_publish import find_variant_key
+
class BaseTest:
"""Empty base test class"""
@@ -45,6 +48,8 @@ class ModuleUnitTest(BaseTest):
ASSET = "test_asset"
TASK = "test_task"
+ TEST_DATA_FOLDER = None
+
@pytest.fixture(scope='session')
def monkeypatch_session(self):
"""Monkeypatch couldn't be used with module or session fixtures."""
@@ -54,25 +59,31 @@ class ModuleUnitTest(BaseTest):
m.undo()
@pytest.fixture(scope="module")
- def download_test_data(self):
- tmpdir = tempfile.mkdtemp()
- for test_file in self.TEST_FILES:
- file_id, file_name, md5 = test_file
+ def download_test_data(self, test_data_folder, persist=False):
+ test_data_folder = test_data_folder or self.TEST_DATA_FOLDER
+ if test_data_folder:
+ print("Using existing folder {}".format(test_data_folder))
+ yield test_data_folder
+ else:
+ tmpdir = tempfile.mkdtemp()
+ for test_file in self.TEST_FILES:
+ file_id, file_name, md5 = test_file
- f_name, ext = os.path.splitext(file_name)
+ f_name, ext = os.path.splitext(file_name)
- RemoteFileHandler.download_file_from_google_drive(file_id,
- str(tmpdir),
- file_name)
+ RemoteFileHandler.download_file_from_google_drive(file_id,
+ str(tmpdir),
+ file_name)
- if ext.lstrip('.') in RemoteFileHandler.IMPLEMENTED_ZIP_FORMATS:
- RemoteFileHandler.unzip(os.path.join(tmpdir, file_name))
- print("Temporary folder created:: {}".format(tmpdir))
- yield tmpdir
+ if ext.lstrip('.') in RemoteFileHandler.IMPLEMENTED_ZIP_FORMATS: # noqa: E501
+ RemoteFileHandler.unzip(os.path.join(tmpdir, file_name))
+ print("Temporary folder created:: {}".format(tmpdir))
+ yield tmpdir
- if not self.PERSIST:
- print("Removing {}".format(tmpdir))
- shutil.rmtree(tmpdir)
+ persist = persist or self.PERSIST
+ if not persist:
+ print("Removing {}".format(tmpdir))
+ shutil.rmtree(tmpdir)
@pytest.fixture(scope="module")
def env_var(self, monkeypatch_session, download_test_data):
@@ -97,13 +108,24 @@ class ModuleUnitTest(BaseTest):
value = value.format(**all_vars)
print("Setting {}:{}".format(key, value))
monkeypatch_session.setenv(key, str(value))
- import openpype
+ #reset connection to openpype DB with new env var
+ import openpype.settings.lib as sett_lib
+ sett_lib._SETTINGS_HANDLER = None
+ sett_lib._LOCAL_SETTINGS_HANDLER = None
+ sett_lib.create_settings_handler()
+ sett_lib.create_local_settings_handler()
+
+ import openpype
openpype_root = os.path.dirname(os.path.dirname(openpype.__file__))
+
# ?? why 2 of those
monkeypatch_session.setenv("OPENPYPE_ROOT", openpype_root)
monkeypatch_session.setenv("OPENPYPE_REPOS_ROOT", openpype_root)
+ # for remapping purposes (currently in Nuke)
+ monkeypatch_session.setenv("TEST_SOURCE_FOLDER", download_test_data)
+
@pytest.fixture(scope="module")
def db_setup(self, download_test_data, env_var, monkeypatch_session):
"""Restore prepared MongoDB dumps into selected DB."""
@@ -111,10 +133,12 @@ class ModuleUnitTest(BaseTest):
uri = os.environ.get("OPENPYPE_MONGO")
db_handler = DBHandler(uri)
- db_handler.setup_from_dump(self.TEST_DB_NAME, backup_dir, True,
+ db_handler.setup_from_dump(self.TEST_DB_NAME, backup_dir,
+ overwrite=True,
db_name_out=self.TEST_DB_NAME)
- db_handler.setup_from_dump("openpype", backup_dir, True,
+ db_handler.setup_from_dump("openpype", backup_dir,
+ overwrite=True,
db_name_out=self.TEST_OPENPYPE_NAME)
yield db_handler
@@ -167,31 +191,76 @@ class PublishTest(ModuleUnitTest):
"""
APP = ""
- APP_VARIANT = ""
-
- APP_NAME = "{}/{}".format(APP, APP_VARIANT)
TIMEOUT = 120 # publish timeout
- @pytest.fixture(scope="module")
- def last_workfile_path(self, download_test_data):
- raise NotImplementedError
+ # could be overwritten by command line arguments
+ # command line value takes precedence
+
+ # keep empty to locate latest installed variant or explicit
+ APP_VARIANT = ""
+ PERSIST = True # True - keep test_db, test_openpype, outputted test files
+ TEST_DATA_FOLDER = None # use specific folder of unzipped test file
@pytest.fixture(scope="module")
- def startup_scripts(self, monkeypatch_session, download_test_data):
- raise NotImplementedError
+ def app_name(self, app_variant):
+ """Returns calculated value for ApplicationManager. Eg.(nuke/12-2)"""
+ from openpype.lib import ApplicationManager
+ app_variant = app_variant or self.APP_VARIANT
+
+ application_manager = ApplicationManager()
+ if not app_variant:
+ app_variant = find_variant_key(application_manager, self.APP)
+
+ yield "{}/{}".format(self.APP, app_variant)
+
+ @pytest.fixture(scope="module")
+ def output_folder_url(self, download_test_data):
+ """Returns location of published data, cleans it first if exists."""
+ path = os.path.join(download_test_data, "output")
+ if os.path.exists(path):
+ print("Purging {}".format(path))
+ shutil.rmtree(path)
+ yield path
+
+ @pytest.fixture(scope="module")
+ def app_args(self, download_test_data):
+ """Returns additional application arguments from a test file.
+
+ Test zip file should contain file at:
+ FOLDER_DIR/input/app_args/app_args.json
+ containing a list of command line arguments (like '-x' etc.)
+ """
+ app_args = []
+ args_url = os.path.join(download_test_data, "input",
+ "app_args", "app_args.json")
+ if not os.path.exists(args_url):
+ print("App argument file {} doesn't exist".format(args_url))
+ else:
+ try:
+ with open(args_url) as json_file:
+ app_args = json.load(json_file)
+
+ if not isinstance(app_args, list):
+ raise ValueError
+ except ValueError:
+ print("{} doesn't contain valid JSON".format(args_url))
+ six.reraise(*sys.exc_info())
+
+ yield app_args
@pytest.fixture(scope="module")
def launched_app(self, dbcon, download_test_data, last_workfile_path,
- startup_scripts):
+ startup_scripts, app_args, app_name, output_folder_url):
"""Launch host app"""
# set publishing folders
- root_key = "config.roots.work.{}".format("windows") # TEMP
+ platform_str = platform.system().lower()
+ root_key = "config.roots.work.{}".format(platform_str)
dbcon.update_one(
{"type": "project"},
{"$set":
{
- root_key: download_test_data
+ root_key: output_folder_url
}}
)
@@ -217,8 +286,11 @@ class PublishTest(ModuleUnitTest):
"asset_name": self.ASSET,
"task_name": self.TASK
}
+ if app_args:
+ data["app_args"] = app_args
- yield application_manager.launch(self.APP_NAME, **data)
+ app_process = application_manager.launch(app_name, **data)
+ yield app_process
@pytest.fixture(scope="module")
def publish_finished(self, dbcon, launched_app, download_test_data):
@@ -236,23 +308,26 @@ class PublishTest(ModuleUnitTest):
yield True
def test_folder_structure_same(self, dbcon, publish_finished,
- download_test_data):
+ download_test_data, output_folder_url):
"""Check if expected and published subfolders contain same files.
Compares only presence, not size nor content!
"""
published_dir_base = download_test_data
- published_dir = os.path.join(published_dir_base,
+ published_dir = os.path.join(output_folder_url,
self.PROJECT,
+ self.ASSET,
self.TASK,
"**")
expected_dir_base = os.path.join(published_dir_base,
"expected")
expected_dir = os.path.join(expected_dir_base,
self.PROJECT,
+ self.ASSET,
self.TASK,
"**")
-
+ print("Comparing published:'{}' : expected:'{}'".format(published_dir,
+ expected_dir))
published = set(f.replace(published_dir_base, '') for f in
glob.glob(published_dir, recursive=True) if
f != published_dir_base and os.path.exists(f))
@@ -262,3 +337,16 @@ class PublishTest(ModuleUnitTest):
not_matched = expected.difference(published)
assert not not_matched, "Missing {} files".format(not_matched)
+
+
+class HostFixtures(PublishTest):
+ """Host specific fixtures. Should be implemented once per host."""
+ @pytest.fixture(scope="module")
+ def last_workfile_path(self, download_test_data, output_folder_url):
+ """Returns url of workfile"""
+ raise NotImplementedError
+
+ @pytest.fixture(scope="module")
+ def startup_scripts(self, monkeypatch_session, download_test_data):
+ """"Adds init scripts (like userSetup) to expected location"""
+ raise NotImplementedError
\ No newline at end of file
diff --git a/tools/build.sh b/tools/build.sh
index bc79f03db7..301f26023a 100755
--- a/tools/build.sh
+++ b/tools/build.sh
@@ -178,10 +178,10 @@ main () {
fi
if [ "$disable_submodule_update" == 1 ]; then
- echo -e "${BIGreen}>>>${RST} Making sure submodules are up-to-date ..."
- git submodule update --init --recursive
+ echo -e "${BIYellow}***${RST} Not updating submodules ..."
else
- echo -e "${BIYellow}***${RST} Not updating submodules ..."
+ echo -e "${BIGreen}>>>${RST} Making sure submodules are up-to-date ..."
+ git submodule update --init --recursive
fi
echo -e "${BIGreen}>>>${RST} Building ..."
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
diff --git a/tools/create_zip.ps1 b/tools/create_zip.ps1
index c27857b480..e33445d1fa 100644
--- a/tools/create_zip.ps1
+++ b/tools/create_zip.ps1
@@ -96,9 +96,9 @@ if (-not (Test-Path -PathType Container -Path "$($env:POETRY_HOME)\bin")) {
Write-Host ">>> " -NoNewline -ForegroundColor green
Write-Host "Cleaning cache files ... " -NoNewline
-Get-ChildItem $openpype_root -Filter "*.pyc" -Force -Recurse | Where-Object { $_.FullName -inotmatch 'build' } | Remove-Item -Force
-Get-ChildItem $openpype_root -Filter "*.pyo" -Force -Recurse | Where-Object { $_.FullName -inotmatch 'build' } | Remove-Item -Force
-Get-ChildItem $openpype_root -Filter "__pycache__" -Force -Recurse| Where-Object { $_.FullName -inotmatch 'build' } | Remove-Item -Force -Recurse
+Get-ChildItem $openpype_root -Filter "__pycache__" -Force -Recurse| Where-Object {( $_.FullName -inotmatch '\\build\\' ) -and ( $_.FullName -inotmatch '\\.venv' )} | Remove-Item -Force -Recurse
+Get-ChildItem $openpype_root -Filter "*.pyc" -Force -Recurse | Where-Object {( $_.FullName -inotmatch '\\build\\' ) -and ( $_.FullName -inotmatch '\\.venv' )} | Remove-Item -Force
+Get-ChildItem $openpype_root -Filter "*.pyo" -Force -Recurse | Where-Object {( $_.FullName -inotmatch '\\build\\' ) -and ( $_.FullName -inotmatch '\\.venv' )} | Remove-Item -Force
Write-Host "OK" -ForegroundColor green
Write-Host ">>> " -NoNewline -ForegroundColor green
diff --git a/vendor/deadline/custom/plugins/GlobalJobPreLoad.py b/vendor/deadline/custom/plugins/GlobalJobPreLoad.py
index 0aa5adaa20..ba1e5f6c6a 100644
--- a/vendor/deadline/custom/plugins/GlobalJobPreLoad.py
+++ b/vendor/deadline/custom/plugins/GlobalJobPreLoad.py
@@ -48,6 +48,7 @@ def inject_openpype_environment(deadlinePlugin):
add_args['asset'] = job.GetJobEnvironmentKeyValue('AVALON_ASSET')
add_args['task'] = job.GetJobEnvironmentKeyValue('AVALON_TASK')
add_args['app'] = job.GetJobEnvironmentKeyValue('AVALON_APP_NAME')
+ add_args["envgroup"] = "farm"
if all(add_args.values()):
for key, value in add_args.items():
diff --git a/website/docs/admin_settings_project_anatomy.md b/website/docs/admin_settings_project_anatomy.md
index 30784686e2..1f742c31ed 100644
--- a/website/docs/admin_settings_project_anatomy.md
+++ b/website/docs/admin_settings_project_anatomy.md
@@ -60,6 +60,7 @@ We have a few required anatomy templates for OpenPype to work properly, however
| `task[name]` | Name of task |
| `task[type]` | Type of task |
| `task[short]` | Shortname of task |
+| `parent` | Name of hierarchical parent |
| `version` | Version number |
| `subset` | Subset name |
| `family` | Main family name |