diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b390da5e..ad2f001b45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,62 +1,93 @@ # Changelog -## [3.8.0-nightly.5](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.8.1](https://github.com/pypeclub/OpenPype/tree/3.8.1) (2022-02-01) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.7.0...HEAD) - -### 📖 Documentation - -- Renamed to proper name [\#2546](https://github.com/pypeclub/OpenPype/pull/2546) -- Slack: Add review to notification message [\#2498](https://github.com/pypeclub/OpenPype/pull/2498) - -**🆕 New features** - -- Flame: OpenTimelineIO Export Modul [\#2398](https://github.com/pypeclub/OpenPype/pull/2398) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.0...3.8.1) **🚀 Enhancements** +- Webpublisher: Thumbnail extractor [\#2600](https://github.com/pypeclub/OpenPype/pull/2600) +- Loader: Allow to toggle default family filters between "include" or "exclude" filtering [\#2541](https://github.com/pypeclub/OpenPype/pull/2541) + +**🐛 Bug fixes** + +- Release/3.8.0 [\#2619](https://github.com/pypeclub/OpenPype/pull/2619) +- hotfix: OIIO tool path - add extension on windows [\#2618](https://github.com/pypeclub/OpenPype/pull/2618) +- Settings: Enum does not store empty string if has single item to select [\#2615](https://github.com/pypeclub/OpenPype/pull/2615) +- switch distutils to sysconfig for `get\_platform\(\)` [\#2594](https://github.com/pypeclub/OpenPype/pull/2594) +- Fix poetry index and speedcopy update [\#2589](https://github.com/pypeclub/OpenPype/pull/2589) +- Webpublisher: Fix - subset names from processed .psd used wrong value for task [\#2586](https://github.com/pypeclub/OpenPype/pull/2586) +- `vrscene` creator Deadline webservice URL handling [\#2580](https://github.com/pypeclub/OpenPype/pull/2580) +- global: track name was failing if duplicated root word in name [\#2568](https://github.com/pypeclub/OpenPype/pull/2568) +- General: Do not validate version if build does not support it [\#2557](https://github.com/pypeclub/OpenPype/pull/2557) +- Validate Maya Rig produces no cycle errors [\#2484](https://github.com/pypeclub/OpenPype/pull/2484) + +**Merged pull requests:** + +- Bump pillow from 8.4.0 to 9.0.0 [\#2595](https://github.com/pypeclub/OpenPype/pull/2595) +- Webpublisher: Skip version collect [\#2591](https://github.com/pypeclub/OpenPype/pull/2591) +- build\(deps\): bump follow-redirects from 1.14.4 to 1.14.7 in /website [\#2534](https://github.com/pypeclub/OpenPype/pull/2534) +- build\(deps\): bump pillow from 8.4.0 to 9.0.0 [\#2523](https://github.com/pypeclub/OpenPype/pull/2523) + +## [3.8.0](https://github.com/pypeclub/OpenPype/tree/3.8.0) (2022-01-24) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.0-nightly.7...3.8.0) + +**🆕 New features** + +- Flame: extracting segments with trans-coding [\#2547](https://github.com/pypeclub/OpenPype/pull/2547) +- Maya : V-Ray Proxy - load all ABC files via proxy [\#2544](https://github.com/pypeclub/OpenPype/pull/2544) +- Maya to Unreal: Extended static mesh workflow [\#2537](https://github.com/pypeclub/OpenPype/pull/2537) +- Flame: collecting publishable instances [\#2519](https://github.com/pypeclub/OpenPype/pull/2519) +- Flame: create publishable clips [\#2495](https://github.com/pypeclub/OpenPype/pull/2495) + +**🚀 Enhancements** + +- Webpublisher: Moved error at the beginning of the log [\#2559](https://github.com/pypeclub/OpenPype/pull/2559) +- Ftrack: Use ApplicationManager to get DJV path [\#2558](https://github.com/pypeclub/OpenPype/pull/2558) +- Webpublisher: Added endpoint to reprocess batch through UI [\#2555](https://github.com/pypeclub/OpenPype/pull/2555) - Settings: PathInput strip passed string [\#2550](https://github.com/pypeclub/OpenPype/pull/2550) +- Global: Exctract Review anatomy fill data with output name [\#2548](https://github.com/pypeclub/OpenPype/pull/2548) +- Cosmetics: Clean up some cosmetics / typos [\#2542](https://github.com/pypeclub/OpenPype/pull/2542) +- Launcher: Added context menu to to skip opening last workfile [\#2536](https://github.com/pypeclub/OpenPype/pull/2536) - General: Validate if current process OpenPype version is requested version [\#2529](https://github.com/pypeclub/OpenPype/pull/2529) - General: Be able to use anatomy data in ffmpeg output arguments [\#2525](https://github.com/pypeclub/OpenPype/pull/2525) - Expose toggle publish plug-in settings for Maya Look Shading Engine Naming [\#2521](https://github.com/pypeclub/OpenPype/pull/2521) - Photoshop: Move implementation to OpenPype [\#2510](https://github.com/pypeclub/OpenPype/pull/2510) -- TimersManager: Move module one hierarchy higher [\#2501](https://github.com/pypeclub/OpenPype/pull/2501) - Slack: notifications are sent with Openpype logo and bot name [\#2499](https://github.com/pypeclub/OpenPype/pull/2499) -- Ftrack: Event handlers settings [\#2496](https://github.com/pypeclub/OpenPype/pull/2496) -- Flame - create publishable clips [\#2495](https://github.com/pypeclub/OpenPype/pull/2495) -- Tools: Fix style and modality of errors in loader and creator [\#2489](https://github.com/pypeclub/OpenPype/pull/2489) -- Project Manager: Remove project button cleanup [\#2482](https://github.com/pypeclub/OpenPype/pull/2482) -- Tools: Be able to change models of tasks and assets widgets [\#2475](https://github.com/pypeclub/OpenPype/pull/2475) -- Publish pype: Reduce publish process defering [\#2464](https://github.com/pypeclub/OpenPype/pull/2464) -- Maya: Improve speed of Collect History logic [\#2460](https://github.com/pypeclub/OpenPype/pull/2460) -- Maya: Validate Rig Controllers - fix Error: in script editor [\#2459](https://github.com/pypeclub/OpenPype/pull/2459) -- Maya: Optimize Validate Locked Normals speed for dense polymeshes [\#2457](https://github.com/pypeclub/OpenPype/pull/2457) -- Fix \#2453 Refactor missing \_get\_reference\_node method [\#2455](https://github.com/pypeclub/OpenPype/pull/2455) -- Houdini: Remove broken unique name counter [\#2450](https://github.com/pypeclub/OpenPype/pull/2450) -- Maya: Improve lib.polyConstraint performance when Select tool is not the active tool context [\#2447](https://github.com/pypeclub/OpenPype/pull/2447) -- General: Validate third party before build [\#2425](https://github.com/pypeclub/OpenPype/pull/2425) -- Maya : add option to not group reference in ReferenceLoader [\#2383](https://github.com/pypeclub/OpenPype/pull/2383) +- Slack: Add review to notification message [\#2498](https://github.com/pypeclub/OpenPype/pull/2498) +- Maya: Collect 'fps' animation data only for "review" instances [\#2486](https://github.com/pypeclub/OpenPype/pull/2486) **🐛 Bug fixes** +- AfterEffects: Fix - removed obsolete import [\#2577](https://github.com/pypeclub/OpenPype/pull/2577) +- General: OpenPype version updates [\#2575](https://github.com/pypeclub/OpenPype/pull/2575) +- Ftrack: Delete action revision [\#2563](https://github.com/pypeclub/OpenPype/pull/2563) +- Webpublisher: ftrack shows incorrect user names [\#2560](https://github.com/pypeclub/OpenPype/pull/2560) +- Webpublisher: Fixed progress reporting [\#2553](https://github.com/pypeclub/OpenPype/pull/2553) +- Fix Maya AssProxyLoader version switch [\#2551](https://github.com/pypeclub/OpenPype/pull/2551) +- General: Fix install thread in igniter [\#2549](https://github.com/pypeclub/OpenPype/pull/2549) +- Houdini: vdbcache family preserve frame numbers on publish integration + enable validate version for Houdini [\#2535](https://github.com/pypeclub/OpenPype/pull/2535) +- Maya: Fix Load VDB to V-Ray [\#2533](https://github.com/pypeclub/OpenPype/pull/2533) +- Maya: ReferenceLoader fix not unique group name error for attach to root [\#2532](https://github.com/pypeclub/OpenPype/pull/2532) +- Maya: namespaced context go back to original namespace when started from inside a namespace [\#2531](https://github.com/pypeclub/OpenPype/pull/2531) +- Fix create zip tool - path argument [\#2522](https://github.com/pypeclub/OpenPype/pull/2522) +- Maya: Fix Extract Look with space in names [\#2518](https://github.com/pypeclub/OpenPype/pull/2518) - Fix published frame content for sequence starting with 0 [\#2513](https://github.com/pypeclub/OpenPype/pull/2513) -- Fix \#2497: reset empty string attributes correctly to "" instead of "None" [\#2506](https://github.com/pypeclub/OpenPype/pull/2506) -- General: Settings work if OpenPypeVersion is available [\#2494](https://github.com/pypeclub/OpenPype/pull/2494) -- General: PYTHONPATH may break OpenPype dependencies [\#2493](https://github.com/pypeclub/OpenPype/pull/2493) -- Workfiles tool: Files widget show files on first show [\#2488](https://github.com/pypeclub/OpenPype/pull/2488) -- General: Custom template paths filter fix [\#2483](https://github.com/pypeclub/OpenPype/pull/2483) -- Loader: Remove always on top flag in tray [\#2480](https://github.com/pypeclub/OpenPype/pull/2480) -- General: Anatomy does not return root envs as unicode [\#2465](https://github.com/pypeclub/OpenPype/pull/2465) -- Maya: Validate Shape Zero do not keep fixed geometry vertices selected/active after repair [\#2456](https://github.com/pypeclub/OpenPype/pull/2456) +- Maya: reset empty string attributes correctly to "" instead of "None" [\#2506](https://github.com/pypeclub/OpenPype/pull/2506) +- Improve FusionPreLaunch hook errors [\#2505](https://github.com/pypeclub/OpenPype/pull/2505) +- General: Modules import function output fix [\#2492](https://github.com/pypeclub/OpenPype/pull/2492) + +### 📖 Documentation + +- Variable in docs renamed to proper name [\#2546](https://github.com/pypeclub/OpenPype/pull/2546) **Merged pull requests:** -- General: Fix install thread in igniter [\#2549](https://github.com/pypeclub/OpenPype/pull/2549) - AfterEffects: Move implementation to OpenPype [\#2543](https://github.com/pypeclub/OpenPype/pull/2543) -- Fix create zip tool - path argument [\#2522](https://github.com/pypeclub/OpenPype/pull/2522) -- General: Modules import function output fix [\#2492](https://github.com/pypeclub/OpenPype/pull/2492) -- AE: fix hiding of alert window below Publish [\#2491](https://github.com/pypeclub/OpenPype/pull/2491) -- Maya: Validate NGONs re-use polyConstraint code from openpype.host.maya.api.lib [\#2458](https://github.com/pypeclub/OpenPype/pull/2458) +- Maya: Remove Maya Look Assigner check on startup [\#2540](https://github.com/pypeclub/OpenPype/pull/2540) +- build\(deps\): bump shelljs from 0.8.4 to 0.8.5 in /website [\#2538](https://github.com/pypeclub/OpenPype/pull/2538) +- Nuke: Merge avalon's implementation into OpenPype [\#2514](https://github.com/pypeclub/OpenPype/pull/2514) ## [3.7.0](https://github.com/pypeclub/OpenPype/tree/3.7.0) (2022-01-04) @@ -65,45 +96,10 @@ **🚀 Enhancements** - General: Workdir extra folders [\#2462](https://github.com/pypeclub/OpenPype/pull/2462) -- Photoshop: New style validations for New publisher [\#2429](https://github.com/pypeclub/OpenPype/pull/2429) -- General: Environment variables groups [\#2424](https://github.com/pypeclub/OpenPype/pull/2424) -- Unreal: Dynamic menu created in Python [\#2422](https://github.com/pypeclub/OpenPype/pull/2422) -- 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) -- General: Run applications as separate processes under linux [\#2408](https://github.com/pypeclub/OpenPype/pull/2408) -- Ftrack: Check existence of object type on recreation [\#2404](https://github.com/pypeclub/OpenPype/pull/2404) -- Enhancement: Global cleanup plugin that explicitly remove paths from context [\#2402](https://github.com/pypeclub/OpenPype/pull/2402) -- General: MongoDB ability to specify replica set groups [\#2401](https://github.com/pypeclub/OpenPype/pull/2401) -- 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) **🐛 Bug fixes** - TVPaint: Create render layer dialog is in front [\#2471](https://github.com/pypeclub/OpenPype/pull/2471) -- Short Pyblish plugin path [\#2428](https://github.com/pypeclub/OpenPype/pull/2428) -- 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) - -**Merged pull requests:** - -- Forced cx\_freeze to include sqlite3 into build [\#2432](https://github.com/pypeclub/OpenPype/pull/2432) -- Maya: Replaced PATH usage with vendored oiio path for maketx utility [\#2405](https://github.com/pypeclub/OpenPype/pull/2405) -- \[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) ## [3.6.4](https://github.com/pypeclub/OpenPype/tree/3.6.4) (2021-11-23) diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py index 5ca2a42510..653f97b3dd 100644 --- a/openpype/hooks/pre_add_last_workfile_arg.py +++ b/openpype/hooks/pre_add_last_workfile_arg.py @@ -6,6 +6,8 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): """Add last workfile path to launch arguments. This is not possible to do for all applications the same way. + Checks 'start_last_workfile', if set to False, it will not open last + workfile. This property is set explicitly in Launcher. """ # Execute after workfile template copy diff --git a/openpype/hooks/pre_global_host_data.py b/openpype/hooks/pre_global_host_data.py index 6b08cdb444..bae967e25f 100644 --- a/openpype/hooks/pre_global_host_data.py +++ b/openpype/hooks/pre_global_host_data.py @@ -43,6 +43,7 @@ class GlobalHostDataHook(PreLaunchHook): "env": self.launch_context.env, + "start_last_workfile": self.data.get("start_last_workfile"), "last_workfile_path": self.data.get("last_workfile_path"), "log": self.log diff --git a/openpype/hooks/pre_non_python_host_launch.py b/openpype/hooks/pre_non_python_host_launch.py index 29e40d28c8..dd193616e6 100644 --- a/openpype/hooks/pre_non_python_host_launch.py +++ b/openpype/hooks/pre_non_python_host_launch.py @@ -40,7 +40,10 @@ class NonPythonHostHook(PreLaunchHook): ) # Add workfile path if exists workfile_path = self.data["last_workfile_path"] - if os.path.exists(workfile_path): + if ( + self.data.get("start_last_workfile") + and workfile_path + and os.path.exists(workfile_path)): new_launch_args.append(workfile_path) # Append as whole list as these areguments should not be separated diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index 15c3382313..74d38751e1 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -3,7 +3,6 @@ import re import tempfile import attr -from avalon import aftereffects import pyblish.api from openpype.settings import get_project_settings diff --git a/openpype/hosts/blender/__init__.py b/openpype/hosts/blender/__init__.py index 747394aad0..3081d3c9ba 100644 --- a/openpype/hosts/blender/__init__.py +++ b/openpype/hosts/blender/__init__.py @@ -5,11 +5,8 @@ def add_implementation_envs(env, _app): """Modify environments to contain all required for implementation.""" # Prepare path to implementation script implementation_user_script_path = os.path.join( - os.environ["OPENPYPE_REPOS_ROOT"], - "repos", - "avalon-core", - "setup", - "blender" + os.path.dirname(os.path.abspath(__file__)), + "blender_addon" ) # Add blender implementation script path to PYTHONPATH diff --git a/openpype/hosts/blender/api/__init__.py b/openpype/hosts/blender/api/__init__.py index ecf4fdf4da..e017d74d91 100644 --- a/openpype/hosts/blender/api/__init__.py +++ b/openpype/hosts/blender/api/__init__.py @@ -1,94 +1,64 @@ -import os -import sys -import traceback +"""Public API -import bpy +Anything that isn't defined here is INTERNAL and unreliable for external use. -from .lib import append_user_scripts +""" -from avalon import api as avalon -from pyblish import api as pyblish +from .pipeline import ( + install, + uninstall, + ls, + publish, + containerise, +) -import openpype.hosts.blender +from .plugin import ( + Creator, + Loader, +) -HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.blender.__file__)) -PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") -PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") -LOAD_PATH = os.path.join(PLUGINS_DIR, "load") -CREATE_PATH = os.path.join(PLUGINS_DIR, "create") -INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") +from .workio import ( + open_file, + save_file, + current_file, + has_unsaved_changes, + file_extensions, + work_root, +) -ORIGINAL_EXCEPTHOOK = sys.excepthook +from .lib import ( + lsattr, + lsattrs, + read, + maintained_selection, + get_selection, + # unique_name, +) -def pype_excepthook_handler(*args): - traceback.print_exception(*args) +__all__ = [ + "install", + "uninstall", + "ls", + "publish", + "containerise", + "Creator", + "Loader", -def install(): - """Install Blender configuration for Avalon.""" - sys.excepthook = pype_excepthook_handler - pyblish.register_plugin_path(str(PUBLISH_PATH)) - avalon.register_plugin_path(avalon.Loader, str(LOAD_PATH)) - avalon.register_plugin_path(avalon.Creator, str(CREATE_PATH)) - append_user_scripts() - avalon.on("new", on_new) - avalon.on("open", on_open) + # Workfiles API + "open_file", + "save_file", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root", - -def uninstall(): - """Uninstall Blender configuration for Avalon.""" - sys.excepthook = ORIGINAL_EXCEPTHOOK - pyblish.deregister_plugin_path(str(PUBLISH_PATH)) - avalon.deregister_plugin_path(avalon.Loader, str(LOAD_PATH)) - avalon.deregister_plugin_path(avalon.Creator, str(CREATE_PATH)) - - -def set_start_end_frames(): - from avalon import io - - asset_name = io.Session["AVALON_ASSET"] - asset_doc = io.find_one({ - "type": "asset", - "name": asset_name - }) - - scene = bpy.context.scene - - # Default scene settings - frameStart = scene.frame_start - frameEnd = scene.frame_end - fps = scene.render.fps - resolution_x = scene.render.resolution_x - resolution_y = scene.render.resolution_y - - # Check if settings are set - data = asset_doc.get("data") - - if not data: - return - - if data.get("frameStart"): - frameStart = data.get("frameStart") - if data.get("frameEnd"): - frameEnd = data.get("frameEnd") - if data.get("fps"): - fps = data.get("fps") - if data.get("resolutionWidth"): - resolution_x = data.get("resolutionWidth") - if data.get("resolutionHeight"): - resolution_y = data.get("resolutionHeight") - - scene.frame_start = frameStart - scene.frame_end = frameEnd - scene.render.fps = fps - scene.render.resolution_x = resolution_x - scene.render.resolution_y = resolution_y - - -def on_new(arg1, arg2): - set_start_end_frames() - - -def on_open(arg1, arg2): - set_start_end_frames() + # Utility functions + "maintained_selection", + "lsattr", + "lsattrs", + "read", + "get_selection", + # "unique_name", +] diff --git a/openpype/hosts/blender/api/icons/pyblish-32x32.png b/openpype/hosts/blender/api/icons/pyblish-32x32.png new file mode 100644 index 0000000000..b34e397e0b Binary files /dev/null and b/openpype/hosts/blender/api/icons/pyblish-32x32.png differ diff --git a/openpype/hosts/blender/api/lib.py b/openpype/hosts/blender/api/lib.py index e7210f7e31..20098c0fe8 100644 --- a/openpype/hosts/blender/api/lib.py +++ b/openpype/hosts/blender/api/lib.py @@ -1,9 +1,16 @@ import os import traceback import importlib +import contextlib +from typing import Dict, List, Union import bpy import addon_utils +from openpype.api import Logger + +from . import pipeline + +log = Logger.get_logger(__name__) def load_scripts(paths): @@ -125,3 +132,155 @@ def append_user_scripts(): except Exception: print("Couldn't load user scripts \"{}\"".format(user_scripts)) traceback.print_exc() + + +def imprint(node: bpy.types.bpy_struct_meta_idprop, data: Dict): + r"""Write `data` to `node` as userDefined attributes + + Arguments: + node: Long name of node + data: Dictionary of key/value pairs + + Example: + >>> import bpy + >>> def compute(): + ... return 6 + ... + >>> bpy.ops.mesh.primitive_cube_add() + >>> cube = bpy.context.view_layer.objects.active + >>> imprint(cube, { + ... "regularString": "myFamily", + ... "computedValue": lambda: compute() + ... }) + ... + >>> cube['avalon']['computedValue'] + 6 + """ + + imprint_data = dict() + + for key, value in data.items(): + if value is None: + continue + + if callable(value): + # Support values evaluated at imprint + value = value() + + if not isinstance(value, (int, float, bool, str, list)): + raise TypeError(f"Unsupported type: {type(value)}") + + imprint_data[key] = value + + pipeline.metadata_update(node, imprint_data) + + +def lsattr(attr: str, + value: Union[str, int, bool, List, Dict, None] = None) -> List: + r"""Return nodes matching `attr` and `value` + + Arguments: + attr: Name of Blender property + value: Value of attribute. If none + is provided, return all nodes with this attribute. + + Example: + >>> lsattr("id", "myId") + ... [bpy.data.objects["myNode"] + >>> lsattr("id") + ... [bpy.data.objects["myNode"], bpy.data.objects["myOtherNode"]] + + Returns: + list + """ + + return lsattrs({attr: value}) + + +def lsattrs(attrs: Dict) -> List: + r"""Return nodes with the given attribute(s). + + Arguments: + attrs: Name and value pairs of expected matches + + Example: + >>> lsattrs({"age": 5}) # Return nodes with an `age` of 5 + # Return nodes with both `age` and `color` of 5 and blue + >>> lsattrs({"age": 5, "color": "blue"}) + + Returns a list. + + """ + + # For now return all objects, not filtered by scene/collection/view_layer. + matches = set() + for coll in dir(bpy.data): + if not isinstance( + getattr(bpy.data, coll), + bpy.types.bpy_prop_collection, + ): + continue + for node in getattr(bpy.data, coll): + for attr, value in attrs.items(): + avalon_prop = node.get(pipeline.AVALON_PROPERTY) + if not avalon_prop: + continue + if (avalon_prop.get(attr) + and (value is None or avalon_prop.get(attr) == value)): + matches.add(node) + return list(matches) + + +def read(node: bpy.types.bpy_struct_meta_idprop): + """Return user-defined attributes from `node`""" + + data = dict(node.get(pipeline.AVALON_PROPERTY)) + + # Ignore hidden/internal data + data = { + key: value + for key, value in data.items() if not key.startswith("_") + } + + return data + + +def get_selection() -> List[bpy.types.Object]: + """Return the selected objects from the current scene.""" + return [obj for obj in bpy.context.scene.objects if obj.select_get()] + + +@contextlib.contextmanager +def maintained_selection(): + r"""Maintain selection during context + + Example: + >>> with maintained_selection(): + ... # Modify selection + ... bpy.ops.object.select_all(action='DESELECT') + >>> # Selection restored + """ + + previous_selection = get_selection() + previous_active = bpy.context.view_layer.objects.active + try: + yield + finally: + # Clear the selection + for node in get_selection(): + node.select_set(state=False) + if previous_selection: + for node in previous_selection: + try: + node.select_set(state=True) + except ReferenceError: + # This could happen if a selected node was deleted during + # the context. + log.exception("Failed to reselect") + continue + try: + bpy.context.view_layer.objects.active = previous_active + except ReferenceError: + # This could happen if the active node was deleted during the + # context. + log.exception("Failed to set active object.") diff --git a/openpype/hosts/blender/api/ops.py b/openpype/hosts/blender/api/ops.py new file mode 100644 index 0000000000..a73ef0133a --- /dev/null +++ b/openpype/hosts/blender/api/ops.py @@ -0,0 +1,410 @@ +"""Blender operators and menus for use with Avalon.""" + +import os +import sys +import platform +import time +import traceback +import collections +from pathlib import Path +from types import ModuleType +from typing import Dict, List, Optional, Union + +from Qt import QtWidgets, QtCore + +import bpy +import bpy.utils.previews + +import avalon.api +from openpype.tools.utils import host_tools +from openpype import style + +from .workio import OpenFileCacher + +PREVIEW_COLLECTIONS: Dict = dict() + +# This seems like a good value to keep the Qt app responsive and doesn't slow +# down Blender. At least on macOS I the interace of Blender gets very laggy if +# you make it smaller. +TIMER_INTERVAL: float = 0.01 + + +class BlenderApplication(QtWidgets.QApplication): + _instance = None + blender_windows = {} + + def __init__(self, *args, **kwargs): + super(BlenderApplication, self).__init__(*args, **kwargs) + self.setQuitOnLastWindowClosed(False) + + self.setStyleSheet(style.load_stylesheet()) + self.lastWindowClosed.connect(self.__class__.reset) + + @classmethod + def get_app(cls): + if cls._instance is None: + cls._instance = cls(sys.argv) + return cls._instance + + @classmethod + def reset(cls): + cls._instance = None + + @classmethod + def store_window(cls, identifier, window): + current_window = cls.get_window(identifier) + cls.blender_windows[identifier] = window + if current_window: + current_window.close() + # current_window.deleteLater() + + @classmethod + def get_window(cls, identifier): + return cls.blender_windows.get(identifier) + + +class MainThreadItem: + """Structure to store information about callback in main thread. + + Item should be used to execute callback in main thread which may be needed + for execution of Qt objects. + + Item store callback (callable variable), arguments and keyword arguments + for the callback. Item hold information about it's process. + """ + not_set = object() + sleep_time = 0.1 + + def __init__(self, callback, *args, **kwargs): + self.done = False + self.exception = self.not_set + self.result = self.not_set + self.callback = callback + self.args = args + self.kwargs = kwargs + + def execute(self): + """Execute callback and store it's result. + + Method must be called from main thread. Item is marked as `done` + when callback execution finished. Store output of callback of exception + information when callback raise one. + """ + print("Executing process in main thread") + if self.done: + print("- item is already processed") + return + + callback = self.callback + args = self.args + kwargs = self.kwargs + print("Running callback: {}".format(str(callback))) + try: + result = callback(*args, **kwargs) + self.result = result + + except Exception: + self.exception = sys.exc_info() + + finally: + print("Done") + self.done = True + + def wait(self): + """Wait for result from main thread. + + This method stops current thread until callback is executed. + + Returns: + object: Output of callback. May be any type or object. + + Raises: + Exception: Reraise any exception that happened during callback + execution. + """ + while not self.done: + print(self.done) + time.sleep(self.sleep_time) + + if self.exception is self.not_set: + return self.result + raise self.exception + + +class GlobalClass: + app = None + main_thread_callbacks = collections.deque() + is_windows = platform.system().lower() == "windows" + + +def execute_in_main_thread(main_thead_item): + print("execute_in_main_thread") + GlobalClass.main_thread_callbacks.append(main_thead_item) + + +def _process_app_events() -> Optional[float]: + """Process the events of the Qt app if the window is still visible. + + If the app has any top level windows and at least one of them is visible + return the time after which this function should be run again. Else return + None, so the function is not run again and will be unregistered. + """ + while GlobalClass.main_thread_callbacks: + main_thread_item = GlobalClass.main_thread_callbacks.popleft() + main_thread_item.execute() + if main_thread_item.exception is not MainThreadItem.not_set: + _clc, val, tb = main_thread_item.exception + msg = str(val) + detail = "\n".join(traceback.format_exception(_clc, val, tb)) + dialog = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Warning, + "Error", + msg) + dialog.setMinimumWidth(500) + dialog.setDetailedText(detail) + dialog.exec_() + + if not GlobalClass.is_windows: + if OpenFileCacher.opening_file: + return TIMER_INTERVAL + + app = GlobalClass.app + if app._instance: + app.processEvents() + return TIMER_INTERVAL + return TIMER_INTERVAL + + +class LaunchQtApp(bpy.types.Operator): + """A Base class for opertors to launch a Qt app.""" + + _app: QtWidgets.QApplication + _window = Union[QtWidgets.QDialog, ModuleType] + _tool_name: str = None + _init_args: Optional[List] = list() + _init_kwargs: Optional[Dict] = dict() + bl_idname: str = None + + def __init__(self): + if self.bl_idname is None: + raise NotImplementedError("Attribute `bl_idname` must be set!") + print(f"Initialising {self.bl_idname}...") + self._app = BlenderApplication.get_app() + GlobalClass.app = self._app + + bpy.app.timers.register( + _process_app_events, + persistent=True + ) + + def execute(self, context): + """Execute the operator. + + The child class must implement `execute()` where it only has to set + `self._window` to the desired Qt window and then simply run + `return super().execute(context)`. + `self._window` is expected to have a `show` method. + If the `show` method requires arguments, you can set `self._show_args` + and `self._show_kwargs`. `args` should be a list, `kwargs` a + dictionary. + """ + + if self._tool_name is None: + if self._window is None: + raise AttributeError("`self._window` is not set.") + + else: + window = self._app.get_window(self.bl_idname) + if window is None: + window = host_tools.get_tool_by_name(self._tool_name) + self._app.store_window(self.bl_idname, window) + self._window = window + + if not isinstance( + self._window, + (QtWidgets.QMainWindow, QtWidgets.QDialog, ModuleType) + ): + raise AttributeError( + "`window` should be a `QDialog or module`. Got: {}".format( + str(type(window)) + ) + ) + + self.before_window_show() + + if isinstance(self._window, ModuleType): + self._window.show() + window = None + if hasattr(self._window, "window"): + window = self._window.window + elif hasattr(self._window, "_window"): + window = self._window.window + + if window: + self._app.store_window(self.bl_idname, window) + + else: + origin_flags = self._window.windowFlags() + on_top_flags = origin_flags | QtCore.Qt.WindowStaysOnTopHint + self._window.setWindowFlags(on_top_flags) + self._window.show() + + if on_top_flags != origin_flags: + self._window.setWindowFlags(origin_flags) + self._window.show() + + return {'FINISHED'} + + def before_window_show(self): + return + + +class LaunchCreator(LaunchQtApp): + """Launch Avalon Creator.""" + + bl_idname = "wm.avalon_creator" + bl_label = "Create..." + _tool_name = "creator" + + def before_window_show(self): + self._window.refresh() + + +class LaunchLoader(LaunchQtApp): + """Launch Avalon Loader.""" + + bl_idname = "wm.avalon_loader" + bl_label = "Load..." + _tool_name = "loader" + + def before_window_show(self): + self._window.set_context( + {"asset": avalon.api.Session["AVALON_ASSET"]}, + refresh=True + ) + + +class LaunchPublisher(LaunchQtApp): + """Launch Avalon Publisher.""" + + bl_idname = "wm.avalon_publisher" + bl_label = "Publish..." + + def execute(self, context): + host_tools.show_publish() + return {"FINISHED"} + + +class LaunchManager(LaunchQtApp): + """Launch Avalon Manager.""" + + bl_idname = "wm.avalon_manager" + bl_label = "Manage..." + _tool_name = "sceneinventory" + + def before_window_show(self): + self._window.refresh() + + +class LaunchWorkFiles(LaunchQtApp): + """Launch Avalon Work Files.""" + + bl_idname = "wm.avalon_workfiles" + bl_label = "Work Files..." + _tool_name = "workfiles" + + def execute(self, context): + result = super().execute(context) + self._window.set_context({ + "asset": avalon.api.Session["AVALON_ASSET"], + "silo": avalon.api.Session["AVALON_SILO"], + "task": avalon.api.Session["AVALON_TASK"] + }) + return result + + def before_window_show(self): + self._window.root = str(Path( + os.environ.get("AVALON_WORKDIR", ""), + os.environ.get("AVALON_SCENEDIR", ""), + )) + self._window.refresh() + + +class TOPBAR_MT_avalon(bpy.types.Menu): + """Avalon menu.""" + + bl_idname = "TOPBAR_MT_avalon" + bl_label = os.environ.get("AVALON_LABEL") + + def draw(self, context): + """Draw the menu in the UI.""" + + layout = self.layout + + pcoll = PREVIEW_COLLECTIONS.get("avalon") + if pcoll: + pyblish_menu_icon = pcoll["pyblish_menu_icon"] + pyblish_menu_icon_id = pyblish_menu_icon.icon_id + else: + pyblish_menu_icon_id = 0 + + asset = avalon.api.Session['AVALON_ASSET'] + task = avalon.api.Session['AVALON_TASK'] + context_label = f"{asset}, {task}" + context_label_item = layout.row() + context_label_item.operator( + LaunchWorkFiles.bl_idname, text=context_label + ) + context_label_item.enabled = False + layout.separator() + layout.operator(LaunchCreator.bl_idname, text="Create...") + layout.operator(LaunchLoader.bl_idname, text="Load...") + layout.operator( + LaunchPublisher.bl_idname, + text="Publish...", + icon_value=pyblish_menu_icon_id, + ) + layout.operator(LaunchManager.bl_idname, text="Manage...") + layout.separator() + layout.operator(LaunchWorkFiles.bl_idname, text="Work Files...") + # TODO (jasper): maybe add 'Reload Pipeline', 'Reset Frame Range' and + # 'Reset Resolution'? + + +def draw_avalon_menu(self, context): + """Draw the Avalon menu in the top bar.""" + + self.layout.menu(TOPBAR_MT_avalon.bl_idname) + + +classes = [ + LaunchCreator, + LaunchLoader, + LaunchPublisher, + LaunchManager, + LaunchWorkFiles, + TOPBAR_MT_avalon, +] + + +def register(): + "Register the operators and menu." + + pcoll = bpy.utils.previews.new() + pyblish_icon_file = Path(__file__).parent / "icons" / "pyblish-32x32.png" + pcoll.load("pyblish_menu_icon", str(pyblish_icon_file.absolute()), 'IMAGE') + PREVIEW_COLLECTIONS["avalon"] = pcoll + + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.TOPBAR_MT_editor_menus.append(draw_avalon_menu) + + +def unregister(): + """Unregister the operators and menu.""" + + pcoll = PREVIEW_COLLECTIONS.pop("avalon") + bpy.utils.previews.remove(pcoll) + bpy.types.TOPBAR_MT_editor_menus.remove(draw_avalon_menu) + for cls in reversed(classes): + bpy.utils.unregister_class(cls) diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py new file mode 100644 index 0000000000..0e5104fea9 --- /dev/null +++ b/openpype/hosts/blender/api/pipeline.py @@ -0,0 +1,427 @@ +import os +import sys +import importlib +import traceback +from typing import Callable, Dict, Iterator, List, Optional + +import bpy + +from . import lib +from . import ops + +import pyblish.api +import avalon.api +from avalon import io, schema +from avalon.pipeline import AVALON_CONTAINER_ID + +from openpype.api import Logger +import openpype.hosts.blender + +HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.blender.__file__)) +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") + +ORIGINAL_EXCEPTHOOK = sys.excepthook + +AVALON_INSTANCES = "AVALON_INSTANCES" +AVALON_CONTAINERS = "AVALON_CONTAINERS" +AVALON_PROPERTY = 'avalon' +IS_HEADLESS = bpy.app.background + +log = Logger.get_logger(__name__) + + +def pype_excepthook_handler(*args): + traceback.print_exception(*args) + + +def install(): + """Install Blender configuration for Avalon.""" + sys.excepthook = pype_excepthook_handler + + pyblish.api.register_host("blender") + pyblish.api.register_plugin_path(str(PUBLISH_PATH)) + + avalon.api.register_plugin_path(avalon.api.Loader, str(LOAD_PATH)) + avalon.api.register_plugin_path(avalon.api.Creator, str(CREATE_PATH)) + + lib.append_user_scripts() + + avalon.api.on("new", on_new) + avalon.api.on("open", on_open) + _register_callbacks() + _register_events() + + if not IS_HEADLESS: + ops.register() + + +def uninstall(): + """Uninstall Blender configuration for Avalon.""" + sys.excepthook = ORIGINAL_EXCEPTHOOK + + pyblish.api.deregister_host("blender") + pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) + + avalon.api.deregister_plugin_path(avalon.api.Loader, str(LOAD_PATH)) + avalon.api.deregister_plugin_path(avalon.api.Creator, str(CREATE_PATH)) + + if not IS_HEADLESS: + ops.unregister() + + +def set_start_end_frames(): + asset_name = io.Session["AVALON_ASSET"] + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) + + scene = bpy.context.scene + + # Default scene settings + frameStart = scene.frame_start + frameEnd = scene.frame_end + fps = scene.render.fps + resolution_x = scene.render.resolution_x + resolution_y = scene.render.resolution_y + + # Check if settings are set + data = asset_doc.get("data") + + if not data: + return + + if data.get("frameStart"): + frameStart = data.get("frameStart") + if data.get("frameEnd"): + frameEnd = data.get("frameEnd") + if data.get("fps"): + fps = data.get("fps") + if data.get("resolutionWidth"): + resolution_x = data.get("resolutionWidth") + if data.get("resolutionHeight"): + resolution_y = data.get("resolutionHeight") + + scene.frame_start = frameStart + scene.frame_end = frameEnd + scene.render.fps = fps + scene.render.resolution_x = resolution_x + scene.render.resolution_y = resolution_y + + +def on_new(arg1, arg2): + set_start_end_frames() + + +def on_open(arg1, arg2): + set_start_end_frames() + + +@bpy.app.handlers.persistent +def _on_save_pre(*args): + avalon.api.emit("before_save", args) + + +@bpy.app.handlers.persistent +def _on_save_post(*args): + avalon.api.emit("save", args) + + +@bpy.app.handlers.persistent +def _on_load_post(*args): + # Detect new file or opening an existing file + if bpy.data.filepath: + # Likely this was an open operation since it has a filepath + avalon.api.emit("open", args) + else: + avalon.api.emit("new", args) + + ops.OpenFileCacher.post_load() + + +def _register_callbacks(): + """Register callbacks for certain events.""" + def _remove_handler(handlers: List, callback: Callable): + """Remove the callback from the given handler list.""" + + try: + handlers.remove(callback) + except ValueError: + pass + + # TODO (jasper): implement on_init callback? + + # Be sure to remove existig ones first. + _remove_handler(bpy.app.handlers.save_pre, _on_save_pre) + _remove_handler(bpy.app.handlers.save_post, _on_save_post) + _remove_handler(bpy.app.handlers.load_post, _on_load_post) + + bpy.app.handlers.save_pre.append(_on_save_pre) + bpy.app.handlers.save_post.append(_on_save_post) + bpy.app.handlers.load_post.append(_on_load_post) + + log.info("Installed event handler _on_save_pre...") + log.info("Installed event handler _on_save_post...") + log.info("Installed event handler _on_load_post...") + + +def _on_task_changed(*args): + """Callback for when the task in the context is changed.""" + + # TODO (jasper): Blender has no concept of projects or workspace. + # It would be nice to override 'bpy.ops.wm.open_mainfile' so it takes the + # workdir as starting directory. But I don't know if that is possible. + # Another option would be to create a custom 'File Selector' and add the + # `directory` attribute, so it opens in that directory (does it?). + # https://docs.blender.org/api/blender2.8/bpy.types.Operator.html#calling-a-file-selector + # https://docs.blender.org/api/blender2.8/bpy.types.WindowManager.html#bpy.types.WindowManager.fileselect_add + workdir = avalon.api.Session["AVALON_WORKDIR"] + log.debug("New working directory: %s", workdir) + + +def _register_events(): + """Install callbacks for specific events.""" + + avalon.api.on("taskChanged", _on_task_changed) + log.info("Installed event callback for 'taskChanged'...") + + +def reload_pipeline(*args): + """Attempt to reload pipeline at run-time. + + Warning: + This is primarily for development and debugging purposes and not well + tested. + + """ + + avalon.api.uninstall() + + for module in ( + "avalon.io", + "avalon.lib", + "avalon.pipeline", + "avalon.tools.creator.app", + "avalon.tools.manager.app", + "avalon.api", + "avalon.tools", + ): + module = importlib.import_module(module) + importlib.reload(module) + + +def _discover_gui() -> Optional[Callable]: + """Return the most desirable of the currently registered GUIs""" + + # Prefer last registered + guis = reversed(pyblish.api.registered_guis()) + + for gui in guis: + try: + gui = __import__(gui).show + except (ImportError, AttributeError): + continue + else: + return gui + + return None + + +def add_to_avalon_container(container: bpy.types.Collection): + """Add the container to the Avalon container.""" + + avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) + if not avalon_container: + avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) + + # Link the container to the scene so it's easily visible to the artist + # and can be managed easily. Otherwise it's only found in "Blender + # File" view and it will be removed by Blenders garbage collection, + # unless you set a 'fake user'. + bpy.context.scene.collection.children.link(avalon_container) + + avalon_container.children.link(container) + + # Disable Avalon containers for the view layers. + for view_layer in bpy.context.scene.view_layers: + for child in view_layer.layer_collection.children: + if child.collection == avalon_container: + child.exclude = True + + +def metadata_update(node: bpy.types.bpy_struct_meta_idprop, data: Dict): + """Imprint the node with metadata. + + Existing metadata will be updated. + """ + + if not node.get(AVALON_PROPERTY): + node[AVALON_PROPERTY] = dict() + for key, value in data.items(): + if value is None: + continue + node[AVALON_PROPERTY][key] = value + + +def containerise(name: str, + namespace: str, + nodes: List, + context: Dict, + loader: Optional[str] = None, + suffix: Optional[str] = "CON") -> bpy.types.Collection: + """Bundle `nodes` into an assembly and imprint it with metadata + + Containerisation enables a tracking of version, author and origin + for loaded assets. + + Arguments: + name: Name of resulting assembly + namespace: Namespace under which to host container + nodes: Long names of nodes to containerise + context: Asset information + loader: Name of loader used to produce this container. + suffix: Suffix of container, defaults to `_CON`. + + Returns: + The container assembly + + """ + + node_name = f"{context['asset']['name']}_{name}" + if namespace: + node_name = f"{namespace}:{node_name}" + if suffix: + node_name = f"{node_name}_{suffix}" + container = bpy.data.collections.new(name=node_name) + # Link the children nodes + for obj in nodes: + container.objects.link(obj) + + data = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace or '', + "loader": str(loader), + "representation": str(context["representation"]["_id"]), + } + + metadata_update(container, data) + add_to_avalon_container(container) + + return container + + +def containerise_existing( + container: bpy.types.Collection, + name: str, + namespace: str, + context: Dict, + loader: Optional[str] = None, + suffix: Optional[str] = "CON") -> bpy.types.Collection: + """Imprint or update container with metadata. + + Arguments: + name: Name of resulting assembly + namespace: Namespace under which to host container + context: Asset information + loader: Name of loader used to produce this container. + suffix: Suffix of container, defaults to `_CON`. + + Returns: + The container assembly + """ + + node_name = container.name + if suffix: + node_name = f"{node_name}_{suffix}" + container.name = node_name + data = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace or '', + "loader": str(loader), + "representation": str(context["representation"]["_id"]), + } + + metadata_update(container, data) + add_to_avalon_container(container) + + return container + + +def parse_container(container: bpy.types.Collection, + validate: bool = True) -> Dict: + """Return the container node's full container data. + + Args: + container: A container node name. + validate: turn the validation for the container on or off + + Returns: + The container schema data for this container node. + + """ + + data = lib.read(container) + + # Append transient data + data["objectName"] = container.name + + if validate: + schema.validate(data) + + return data + + +def ls() -> Iterator: + """List containers from active Blender scene. + + This is the host-equivalent of api.ls(), but instead of listing assets on + disk, it lists assets already loaded in Blender; once loaded they are + called containers. + """ + + for container in lib.lsattr("id", AVALON_CONTAINER_ID): + yield parse_container(container) + + +def update_hierarchy(containers): + """Hierarchical container support + + This is the function to support Scene Inventory to draw hierarchical + view for containers. + + We need both parent and children to visualize the graph. + + """ + + all_containers = set(ls()) # lookup set + + for container in containers: + # Find parent + # FIXME (jasperge): re-evaluate this. How would it be possible + # to 'nest' assets? Collections can have several parents, for + # now assume it has only 1 parent + parent = [ + coll for coll in bpy.data.collections if container in coll.children + ] + for node in parent: + if node in all_containers: + container["parent"] = node + break + + log.debug("Container: %s", container) + + yield container + + +def publish(): + """Shorthand to publish from within host.""" + + return pyblish.util.publish() diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 6d437059b8..1f0b142ef6 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -5,10 +5,17 @@ from typing import Dict, List, Optional import bpy -from avalon import api, blender -from avalon.blender import ops -from avalon.blender.pipeline import AVALON_CONTAINERS +import avalon.api from openpype.api import PypeCreatorMixin +from .pipeline import AVALON_CONTAINERS +from .ops import ( + MainThreadItem, + execute_in_main_thread +) +from .lib import ( + imprint, + get_selection +) VALID_EXTENSIONS = [".blend", ".json", ".abc", ".fbx"] @@ -42,10 +49,13 @@ def get_unique_number( return f"{count:0>2}" -def prepare_data(data, container_name): +def prepare_data(data, container_name=None): name = data.name local_data = data.make_local() - local_data.name = f"{container_name}:{name}" + if container_name: + local_data.name = f"{container_name}:{name}" + else: + local_data.name = f"{name}" return local_data @@ -119,11 +129,27 @@ def deselect_all(): bpy.context.view_layer.objects.active = active -class Creator(PypeCreatorMixin, blender.Creator): - pass +class Creator(PypeCreatorMixin, avalon.api.Creator): + """Base class for Creator plug-ins.""" + def process(self): + collection = bpy.data.collections.new(name=self.data["subset"]) + bpy.context.scene.collection.children.link(collection) + imprint(collection, self.data) + + if (self.options or {}).get("useSelection"): + for obj in get_selection(): + collection.objects.link(obj) + + return collection -class AssetLoader(api.Loader): +class Loader(avalon.api.Loader): + """Base class for Loader plug-ins.""" + + hosts = ["blender"] + + +class AssetLoader(avalon.api.Loader): """A basic AssetLoader for Blender This will implement the basic logic for linking/appending assets @@ -191,8 +217,8 @@ class AssetLoader(api.Loader): namespace: Optional[str] = None, options: Optional[Dict] = None) -> Optional[bpy.types.Collection]: """ Run the loader on Blender main thread""" - mti = ops.MainThreadItem(self._load, context, name, namespace, options) - ops.execute_in_main_thread(mti) + mti = MainThreadItem(self._load, context, name, namespace, options) + execute_in_main_thread(mti) def _load(self, context: dict, @@ -257,8 +283,8 @@ class AssetLoader(api.Loader): def update(self, container: Dict, representation: Dict): """ Run the update on Blender main thread""" - mti = ops.MainThreadItem(self.exec_update, container, representation) - ops.execute_in_main_thread(mti) + mti = MainThreadItem(self.exec_update, container, representation) + execute_in_main_thread(mti) def exec_remove(self, container: Dict) -> bool: """Must be implemented by a sub-class""" @@ -266,5 +292,5 @@ class AssetLoader(api.Loader): def remove(self, container: Dict) -> bool: """ Run the remove on Blender main thread""" - mti = ops.MainThreadItem(self.exec_remove, container) - ops.execute_in_main_thread(mti) + mti = MainThreadItem(self.exec_remove, container) + execute_in_main_thread(mti) diff --git a/openpype/hosts/blender/api/workio.py b/openpype/hosts/blender/api/workio.py new file mode 100644 index 0000000000..fd68761982 --- /dev/null +++ b/openpype/hosts/blender/api/workio.py @@ -0,0 +1,90 @@ +"""Host API required for Work Files.""" + +from pathlib import Path +from typing import List, Optional + +import bpy +from avalon import api + + +class OpenFileCacher: + """Store information about opening file. + + When file is opening QApplcation events should not be processed. + """ + opening_file = False + + @classmethod + def post_load(cls): + cls.opening_file = False + + @classmethod + def set_opening(cls): + cls.opening_file = True + + +def open_file(filepath: str) -> Optional[str]: + """Open the scene file in Blender.""" + OpenFileCacher.set_opening() + + preferences = bpy.context.preferences + load_ui = preferences.filepaths.use_load_ui + use_scripts = preferences.filepaths.use_scripts_auto_execute + result = bpy.ops.wm.open_mainfile( + filepath=filepath, + load_ui=load_ui, + use_scripts=use_scripts, + ) + + if result == {'FINISHED'}: + return filepath + return None + + +def save_file(filepath: str, copy: bool = False) -> Optional[str]: + """Save the open scene file.""" + + preferences = bpy.context.preferences + compress = preferences.filepaths.use_file_compression + relative_remap = preferences.filepaths.use_relative_paths + result = bpy.ops.wm.save_as_mainfile( + filepath=filepath, + compress=compress, + relative_remap=relative_remap, + copy=copy, + ) + + if result == {'FINISHED'}: + return filepath + return None + + +def current_file() -> Optional[str]: + """Return the path of the open scene file.""" + + current_filepath = bpy.data.filepath + if Path(current_filepath).is_file(): + return current_filepath + return None + + +def has_unsaved_changes() -> bool: + """Does the open scene file have unsaved changes?""" + + return bpy.data.is_dirty + + +def file_extensions() -> List[str]: + """Return the supported file extensions for Blender scene files.""" + + return api.HOST_WORKFILE_EXTENSIONS["blender"] + + +def work_root(session: dict) -> str: + """Return the default root to browse for work files.""" + + work_dir = session["AVALON_WORKDIR"] + scene_dir = session.get("AVALON_SCENEDIR") + if scene_dir: + return str(Path(work_dir, scene_dir)) + return work_dir diff --git a/openpype/hosts/blender/blender_addon/startup/init.py b/openpype/hosts/blender/blender_addon/startup/init.py new file mode 100644 index 0000000000..e43373bc6c --- /dev/null +++ b/openpype/hosts/blender/blender_addon/startup/init.py @@ -0,0 +1,4 @@ +from avalon import pipeline +from openpype.hosts.blender import api + +pipeline.install(api) diff --git a/openpype/hosts/blender/plugins/__init__.py b/openpype/hosts/blender/plugins/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpype/hosts/blender/plugins/create/__init__.py b/openpype/hosts/blender/plugins/create/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index f7bb2bfc26..5f66f5da6e 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -4,7 +4,7 @@ import bpy from avalon import api import openpype.hosts.blender.api.plugin -from avalon.blender import lib +from openpype.hosts.blender.api import lib class CreateAction(openpype.hosts.blender.api.plugin.Creator): diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index 3b4cabe8ec..b88010ae90 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -3,9 +3,8 @@ import bpy from avalon import api -from avalon.blender import lib, ops -from avalon.blender.pipeline import AVALON_INSTANCES -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateAnimation(plugin.Creator): diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index 6fa80b5a5d..cc796d464d 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -3,9 +3,8 @@ import bpy from avalon import api -from avalon.blender import lib, ops -from avalon.blender.pipeline import AVALON_INSTANCES -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateCamera(plugin.Creator): diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index dac12e19b1..f62cbc52ba 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -3,9 +3,8 @@ import bpy from avalon import api -from avalon.blender import lib, ops -from avalon.blender.pipeline import AVALON_INSTANCES -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateLayout(plugin.Creator): diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index 903b70033b..75c90f9bb1 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -3,9 +3,8 @@ import bpy from avalon import api -from avalon.blender import lib, ops -from avalon.blender.pipeline import AVALON_INSTANCES -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateModel(plugin.Creator): diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index 03a468f82e..bf5a84048f 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -3,8 +3,8 @@ import bpy from avalon import api -from avalon.blender import lib import openpype.hosts.blender.api.plugin +from openpype.hosts.blender.api import lib class CreatePointcache(openpype.hosts.blender.api.plugin.Creator): diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index ec74e279c6..65f5061924 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -3,9 +3,8 @@ import bpy from avalon import api -from avalon.blender import lib, ops -from avalon.blender.pipeline import AVALON_INSTANCES -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateRig(plugin.Creator): diff --git a/openpype/hosts/blender/plugins/load/__init__.py b/openpype/hosts/blender/plugins/load/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 5969432c36..07800521c9 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -7,11 +7,12 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender import lib -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) +from openpype.hosts.blender.api import plugin, lib class CacheModelLoader(plugin.AssetLoader): diff --git a/openpype/hosts/blender/plugins/load/load_animation.py b/openpype/hosts/blender/plugins/load/load_animation.py index 47c48248b2..6b8d4abd04 100644 --- a/openpype/hosts/blender/plugins/load/load_animation.py +++ b/openpype/hosts/blender/plugins/load/load_animation.py @@ -1,16 +1,11 @@ """Load an animation in Blender.""" -import logging from typing import Dict, List, Optional import bpy -from avalon.blender.pipeline import AVALON_PROPERTY from openpype.hosts.blender.api import plugin - - -logger = logging.getLogger("openpype").getChild( - "blender").getChild("load_animation") +from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY class BlendAnimationLoader(plugin.AssetLoader): diff --git a/openpype/hosts/blender/plugins/load/load_audio.py b/openpype/hosts/blender/plugins/load/load_audio.py index 660e4d7890..e065150c15 100644 --- a/openpype/hosts/blender/plugins/load/load_audio.py +++ b/openpype/hosts/blender/plugins/load/load_audio.py @@ -7,10 +7,12 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) class AudioLoader(plugin.AssetLoader): diff --git a/openpype/hosts/blender/plugins/load/load_camera_blend.py b/openpype/hosts/blender/plugins/load/load_camera_blend.py index 834eb467d8..61955f124d 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_blend.py +++ b/openpype/hosts/blender/plugins/load/load_camera_blend.py @@ -8,10 +8,12 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) logger = logging.getLogger("openpype").getChild( "blender").getChild("load_camera") diff --git a/openpype/hosts/blender/plugins/load/load_camera_fbx.py b/openpype/hosts/blender/plugins/load/load_camera_fbx.py index 5edba7ec0c..175ddacf9f 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_camera_fbx.py @@ -7,11 +7,12 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender import lib -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api import plugin, lib +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) class FbxCameraLoader(plugin.AssetLoader): diff --git a/openpype/hosts/blender/plugins/load/load_fbx.py b/openpype/hosts/blender/plugins/load/load_fbx.py index 5f69aecb1a..c6e6af5592 100644 --- a/openpype/hosts/blender/plugins/load/load_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_fbx.py @@ -7,11 +7,12 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender import lib -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api import plugin, lib +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) class FbxModelLoader(plugin.AssetLoader): diff --git a/openpype/hosts/blender/plugins/load/load_layout_blend.py b/openpype/hosts/blender/plugins/load/load_layout_blend.py index 4c1f751a77..8029c38b4a 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_blend.py +++ b/openpype/hosts/blender/plugins/load/load_layout_blend.py @@ -7,10 +7,13 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY +from openpype import lib from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) class BlendLayoutLoader(plugin.AssetLoader): @@ -59,7 +62,9 @@ class BlendLayoutLoader(plugin.AssetLoader): library = bpy.data.libraries.get(bpy.path.basename(libpath)) bpy.data.libraries.remove(library) - def _process(self, libpath, asset_group, group_name, actions): + def _process( + self, libpath, asset_group, group_name, asset, representation, actions + ): with bpy.data.libraries.load( libpath, link=True, relative=False ) as (data_from, data_to): @@ -72,7 +77,8 @@ class BlendLayoutLoader(plugin.AssetLoader): container = None for empty in empties: - if empty.get(AVALON_PROPERTY): + if (empty.get(AVALON_PROPERTY) and + empty.get(AVALON_PROPERTY).get('family') == 'layout'): container = empty break @@ -83,12 +89,16 @@ class BlendLayoutLoader(plugin.AssetLoader): objects = [] nodes = list(container.children) - for obj in nodes: - obj.parent = asset_group + allowed_types = ['ARMATURE', 'MESH', 'EMPTY'] for obj in nodes: - objects.append(obj) - nodes.extend(list(obj.children)) + if obj.type in allowed_types: + obj.parent = asset_group + + for obj in nodes: + if obj.type in allowed_types: + objects.append(obj) + nodes.extend(list(obj.children)) objects.reverse() @@ -106,7 +116,7 @@ class BlendLayoutLoader(plugin.AssetLoader): parent.objects.link(obj) for obj in objects: - local_obj = plugin.prepare_data(obj, group_name) + local_obj = plugin.prepare_data(obj) action = None @@ -114,7 +124,7 @@ class BlendLayoutLoader(plugin.AssetLoader): action = actions.get(local_obj.name, None) if local_obj.type == 'MESH': - plugin.prepare_data(local_obj.data, group_name) + plugin.prepare_data(local_obj.data) if obj != local_obj: for constraint in constraints: @@ -123,15 +133,18 @@ class BlendLayoutLoader(plugin.AssetLoader): for material_slot in local_obj.material_slots: if material_slot.material: - plugin.prepare_data(material_slot.material, group_name) + plugin.prepare_data(material_slot.material) elif local_obj.type == 'ARMATURE': - plugin.prepare_data(local_obj.data, group_name) + plugin.prepare_data(local_obj.data) if action is not None: + if local_obj.animation_data is None: + local_obj.animation_data_create() local_obj.animation_data.action = action - elif local_obj.animation_data.action is not None: + elif (local_obj.animation_data and + local_obj.animation_data.action is not None): plugin.prepare_data( - local_obj.animation_data.action, group_name) + local_obj.animation_data.action) # Set link the drivers to the local object if local_obj.data.animation_data: @@ -140,6 +153,21 @@ class BlendLayoutLoader(plugin.AssetLoader): for t in v.targets: t.id = local_obj + elif local_obj.type == 'EMPTY': + creator_plugin = lib.get_creator_by_name("CreateAnimation") + if not creator_plugin: + raise ValueError("Creator plugin \"CreateAnimation\" was " + "not found.") + + api.create( + creator_plugin, + name=local_obj.name.split(':')[-1] + "_animation", + asset=asset, + options={"useSelection": False, + "asset_group": local_obj}, + data={"dependencies": representation} + ) + if not local_obj.get(AVALON_PROPERTY): local_obj[AVALON_PROPERTY] = dict() @@ -148,7 +176,63 @@ class BlendLayoutLoader(plugin.AssetLoader): objects.reverse() - bpy.data.orphans_purge(do_local_ids=False) + armatures = [ + obj for obj in bpy.data.objects + if obj.type == 'ARMATURE' and obj.library is None] + arm_act = {} + + # The armatures with an animation need to be at the center of the + # scene to be hooked correctly by the curves modifiers. + for armature in armatures: + if armature.animation_data and armature.animation_data.action: + arm_act[armature] = armature.animation_data.action + armature.animation_data.action = None + armature.location = (0.0, 0.0, 0.0) + for bone in armature.pose.bones: + bone.location = (0.0, 0.0, 0.0) + bone.rotation_euler = (0.0, 0.0, 0.0) + + curves = [obj for obj in data_to.objects if obj.type == 'CURVE'] + + for curve in curves: + curve_name = curve.name.split(':')[0] + curve_obj = bpy.data.objects.get(curve_name) + + local_obj = plugin.prepare_data(curve) + plugin.prepare_data(local_obj.data) + + # Curves need to reset the hook, but to do that they need to be + # in the view layer. + parent.objects.link(local_obj) + plugin.deselect_all() + local_obj.select_set(True) + bpy.context.view_layer.objects.active = local_obj + if local_obj.library is None: + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.object.hook_reset() + bpy.ops.object.mode_set(mode='OBJECT') + parent.objects.unlink(local_obj) + + local_obj.use_fake_user = True + + for mod in local_obj.modifiers: + mod.object = bpy.data.objects.get(f"{mod.object.name}") + + if not local_obj.get(AVALON_PROPERTY): + local_obj[AVALON_PROPERTY] = dict() + + avalon_info = local_obj[AVALON_PROPERTY] + avalon_info.update({"container_name": group_name}) + + local_obj.parent = curve_obj + objects.append(local_obj) + + for armature in armatures: + if arm_act.get(armature): + armature.animation_data.action = arm_act[armature] + + while bpy.data.orphans_purge(do_local_ids=False): + pass plugin.deselect_all() @@ -168,6 +252,7 @@ class BlendLayoutLoader(plugin.AssetLoader): libpath = self.fname asset = context["asset"]["name"] subset = context["subset"]["name"] + representation = str(context["representation"]["_id"]) asset_name = plugin.asset_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) @@ -183,7 +268,8 @@ class BlendLayoutLoader(plugin.AssetLoader): asset_group.empty_display_type = 'SINGLE_ARROW' avalon_container.objects.link(asset_group) - objects = self._process(libpath, asset_group, group_name, None) + objects = self._process( + libpath, asset_group, group_name, asset, representation, None) for child in asset_group.children: if child.get(AVALON_PROPERTY): diff --git a/openpype/hosts/blender/plugins/load/load_layout_json.py b/openpype/hosts/blender/plugins/load/load_layout_json.py index 442cf05d85..0a5bdeecaa 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_json.py +++ b/openpype/hosts/blender/plugins/load/load_layout_json.py @@ -1,18 +1,20 @@ """Load a layout in Blender.""" +import json from pathlib import Path from pprint import pformat from typing import Dict, Optional import bpy -import json from avalon import api -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY -from avalon.blender.pipeline import AVALON_INSTANCES from openpype import lib +from openpype.hosts.blender.api.pipeline import ( + AVALON_INSTANCES, + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) from openpype.hosts.blender.api import plugin @@ -92,6 +94,10 @@ class JsonLayoutLoader(plugin.AssetLoader): 'animation_asset': asset } + if element.get('animation'): + options['animation_file'] = str(Path(libpath).with_suffix( + '')) + "." + element.get('animation') + # This should return the loaded asset, but the load call will be # added to the queue to run in the Blender main thread, so # at this time it will not return anything. The assets will be @@ -104,20 +110,22 @@ class JsonLayoutLoader(plugin.AssetLoader): options=options ) - # Create the camera asset and the camera instance - creator_plugin = lib.get_creator_by_name("CreateCamera") - if not creator_plugin: - raise ValueError("Creator plugin \"CreateCamera\" was " - "not found.") + # Camera creation when loading a layout is not necessary for now, + # but the code is worth keeping in case we need it in the future. + # # Create the camera asset and the camera instance + # creator_plugin = lib.get_creator_by_name("CreateCamera") + # if not creator_plugin: + # raise ValueError("Creator plugin \"CreateCamera\" was " + # "not found.") - api.create( - creator_plugin, - name="camera", - # name=f"{unique_number}_{subset}_animation", - asset=asset, - options={"useSelection": False} - # data={"dependencies": str(context["representation"]["_id"])} - ) + # api.create( + # creator_plugin, + # name="camera", + # # name=f"{unique_number}_{subset}_animation", + # asset=asset, + # options={"useSelection": False} + # # data={"dependencies": str(context["representation"]["_id"])} + # ) def process_asset(self, context: dict, diff --git a/openpype/hosts/blender/plugins/load/load_look.py b/openpype/hosts/blender/plugins/load/load_look.py index 279af2b626..066ec0101b 100644 --- a/openpype/hosts/blender/plugins/load/load_look.py +++ b/openpype/hosts/blender/plugins/load/load_look.py @@ -8,8 +8,12 @@ import os import json import bpy -from avalon import api, blender -import openpype.hosts.blender.api.plugin as plugin +from avalon import api +from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import ( + containerise_existing, + AVALON_PROPERTY +) class BlendLookLoader(plugin.AssetLoader): @@ -105,7 +109,7 @@ class BlendLookLoader(plugin.AssetLoader): container = bpy.data.collections.new(lib_container) container.name = container_name - blender.pipeline.containerise_existing( + containerise_existing( container, name, namespace, @@ -113,7 +117,7 @@ class BlendLookLoader(plugin.AssetLoader): self.__class__.__name__, ) - metadata = container.get(blender.pipeline.AVALON_PROPERTY) + metadata = container.get(AVALON_PROPERTY) metadata["libpath"] = libpath metadata["lib_container"] = lib_container @@ -161,7 +165,7 @@ class BlendLookLoader(plugin.AssetLoader): f"Unsupported file: {libpath}" ) - collection_metadata = collection.get(blender.pipeline.AVALON_PROPERTY) + collection_metadata = collection.get(AVALON_PROPERTY) collection_libpath = collection_metadata["libpath"] normalized_collection_libpath = ( @@ -204,7 +208,7 @@ class BlendLookLoader(plugin.AssetLoader): if not collection: return False - collection_metadata = collection.get(blender.pipeline.AVALON_PROPERTY) + collection_metadata = collection.get(AVALON_PROPERTY) for obj in collection_metadata['objects']: for child in self.get_all_children(obj): diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index c33c656dec..04ece0b338 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -7,10 +7,12 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) class BlendModelLoader(plugin.AssetLoader): @@ -81,7 +83,8 @@ class BlendModelLoader(plugin.AssetLoader): plugin.prepare_data(local_obj.data, group_name) for material_slot in local_obj.material_slots: - plugin.prepare_data(material_slot.material, group_name) + if material_slot.material: + plugin.prepare_data(material_slot.material, group_name) if not local_obj.get(AVALON_PROPERTY): local_obj[AVALON_PROPERTY] = dict() @@ -245,7 +248,8 @@ class BlendModelLoader(plugin.AssetLoader): # If it is the last object to use that library, remove it if count == 1: library = bpy.data.libraries.get(bpy.path.basename(group_libpath)) - bpy.data.libraries.remove(library) + if library: + bpy.data.libraries.remove(library) self._process(str(libpath), asset_group, object_name) @@ -253,6 +257,7 @@ class BlendModelLoader(plugin.AssetLoader): metadata["libpath"] = str(libpath) metadata["representation"] = str(representation["_id"]) + metadata["parent"] = str(representation["parent"]) def exec_remove(self, container: Dict) -> bool: """Remove an existing container from a Blender scene. diff --git a/openpype/hosts/blender/plugins/load/load_rig.py b/openpype/hosts/blender/plugins/load/load_rig.py index e80da8af45..eb6d273a51 100644 --- a/openpype/hosts/blender/plugins/load/load_rig.py +++ b/openpype/hosts/blender/plugins/load/load_rig.py @@ -7,11 +7,14 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY +from avalon.blender import lib as avalon_lib from openpype import lib from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) class BlendRigLoader(plugin.AssetLoader): @@ -110,6 +113,8 @@ class BlendRigLoader(plugin.AssetLoader): plugin.prepare_data(local_obj.data, group_name) if action is not None: + if local_obj.animation_data is None: + local_obj.animation_data_create() local_obj.animation_data.action = action elif (local_obj.animation_data and local_obj.animation_data.action is not None): @@ -194,12 +199,14 @@ class BlendRigLoader(plugin.AssetLoader): plugin.deselect_all() create_animation = False + anim_file = None if options is not None: parent = options.get('parent') transform = options.get('transform') action = options.get('action') create_animation = options.get('create_animation') + anim_file = options.get('animation_file') if parent and transform: location = transform.get('translation') @@ -252,6 +259,26 @@ class BlendRigLoader(plugin.AssetLoader): plugin.deselect_all() + if anim_file: + bpy.ops.import_scene.fbx(filepath=anim_file, anim_offset=0.0) + + imported = avalon_lib.get_selection() + + armature = [ + o for o in asset_group.children if o.type == 'ARMATURE'][0] + + imported_group = [ + o for o in imported if o.type == 'EMPTY'][0] + + for obj in imported: + if obj.type == 'ARMATURE': + if not armature.animation_data: + armature.animation_data_create() + armature.animation_data.action = obj.animation_data.action + + self._remove(imported_group) + bpy.data.objects.remove(imported_group) + bpy.context.scene.collection.objects.link(asset_group) asset_group[AVALON_PROPERTY] = { @@ -348,6 +375,7 @@ class BlendRigLoader(plugin.AssetLoader): metadata["libpath"] = str(libpath) metadata["representation"] = str(representation["_id"]) + metadata["parent"] = str(representation["parent"]) def exec_remove(self, container: Dict) -> bool: """Remove an existing asset group from a Blender scene. diff --git a/openpype/hosts/blender/plugins/publish/__init__.py b/openpype/hosts/blender/plugins/publish/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index 0d683dace4..bc4b5ab092 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -1,11 +1,13 @@ +import json from typing import Generator import bpy -import json import pyblish.api -from avalon.blender.pipeline import AVALON_PROPERTY -from avalon.blender.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api.pipeline import ( + AVALON_INSTANCES, + AVALON_PROPERTY, +) class CollectInstances(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index b75bec4e28..a26a92f7e4 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -1,10 +1,10 @@ import os +import bpy + from openpype import api from openpype.hosts.blender.api import plugin -from avalon.blender.pipeline import AVALON_PROPERTY - -import bpy +from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY class ExtractABC(api.Extractor): diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py index 565e2fe425..9add633f05 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend.py @@ -2,7 +2,6 @@ import os import bpy -# import avalon.blender.workio import openpype.api diff --git a/openpype/hosts/blender/plugins/publish/extract_blend_animation.py b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py index 239ca53f98..4917223331 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py @@ -29,12 +29,13 @@ class ExtractBlendAnimation(openpype.api.Extractor): if isinstance(obj, bpy.types.Object) and obj.type == 'EMPTY': child = obj.children[0] if child and child.type == 'ARMATURE': - if not obj.animation_data: - obj.animation_data_create() - obj.animation_data.action = child.animation_data.action - obj.animation_data_clear() - data_blocks.add(child.animation_data.action) - data_blocks.add(obj) + if child.animation_data and child.animation_data.action: + if not obj.animation_data: + obj.animation_data_create() + obj.animation_data.action = child.animation_data.action + obj.animation_data_clear() + data_blocks.add(child.animation_data.action) + data_blocks.add(obj) bpy.data.libraries.write(filepath, data_blocks) diff --git a/openpype/hosts/blender/plugins/publish/extract_camera.py b/openpype/hosts/blender/plugins/publish/extract_camera.py index a0e78178c8..597dcecd21 100644 --- a/openpype/hosts/blender/plugins/publish/extract_camera.py +++ b/openpype/hosts/blender/plugins/publish/extract_camera.py @@ -1,10 +1,10 @@ import os +import bpy + from openpype import api from openpype.hosts.blender.api import plugin -import bpy - class ExtractCamera(api.Extractor): """Extract as the camera as FBX.""" diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx.py b/openpype/hosts/blender/plugins/publish/extract_fbx.py index f9ffdea1d1..3ac66f33a4 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx.py @@ -1,10 +1,10 @@ import os +import bpy + from openpype import api from openpype.hosts.blender.api import plugin -from avalon.blender.pipeline import AVALON_PROPERTY - -import bpy +from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY class ExtractFBX(api.Extractor): @@ -50,6 +50,9 @@ class ExtractFBX(api.Extractor): new_materials.append(mat) new_materials_objs.append(obj) + scale_length = bpy.context.scene.unit_settings.scale_length + bpy.context.scene.unit_settings.scale_length = 0.01 + # We export the fbx bpy.ops.export_scene.fbx( context, @@ -60,6 +63,8 @@ class ExtractFBX(api.Extractor): add_leaf_bones=False ) + bpy.context.scene.unit_settings.scale_length = scale_length + plugin.deselect_all() for mat in new_materials: diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py index 16443b760c..4b4a92932a 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py @@ -7,7 +7,7 @@ import bpy_extras.anim_utils from openpype import api from openpype.hosts.blender.api import plugin -from avalon.blender.pipeline import AVALON_PROPERTY +from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY class ExtractAnimationFBX(api.Extractor): @@ -37,13 +37,6 @@ class ExtractAnimationFBX(api.Extractor): armature = [ obj for obj in asset_group.children if obj.type == 'ARMATURE'][0] - asset_group_name = asset_group.name - asset_group.name = asset_group.get(AVALON_PROPERTY).get("asset_name") - - armature_name = armature.name - original_name = armature_name.split(':')[1] - armature.name = original_name - object_action_pairs = [] original_actions = [] @@ -66,6 +59,13 @@ class ExtractAnimationFBX(api.Extractor): self.log.info("Object have no animation.") return + asset_group_name = asset_group.name + asset_group.name = asset_group.get(AVALON_PROPERTY).get("asset_name") + + armature_name = armature.name + original_name = armature_name.split(':')[1] + armature.name = original_name + object_action_pairs.append((armature, copy_action)) original_actions.append(curr_action) @@ -123,7 +123,7 @@ class ExtractAnimationFBX(api.Extractor): json_path = os.path.join(stagingdir, json_filename) json_dict = { - "instance_name": asset_group.get(AVALON_PROPERTY).get("namespace") + "instance_name": asset_group.get(AVALON_PROPERTY).get("objectName") } # collection = instance.data.get("name") diff --git a/openpype/hosts/blender/plugins/publish/extract_layout.py b/openpype/hosts/blender/plugins/publish/extract_layout.py index cd081b4479..cc7c90f4c8 100644 --- a/openpype/hosts/blender/plugins/publish/extract_layout.py +++ b/openpype/hosts/blender/plugins/publish/extract_layout.py @@ -2,9 +2,12 @@ import os import json import bpy +import bpy_extras +import bpy_extras.anim_utils from avalon import io -from avalon.blender.pipeline import AVALON_PROPERTY +from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY import openpype.api @@ -16,6 +19,99 @@ class ExtractLayout(openpype.api.Extractor): families = ["layout"] optional = True + def _export_animation(self, asset, instance, stagingdir, fbx_count): + n = fbx_count + + for obj in asset.children: + if obj.type != "ARMATURE": + continue + + object_action_pairs = [] + original_actions = [] + + starting_frames = [] + ending_frames = [] + + # For each armature, we make a copy of the current action + curr_action = None + copy_action = None + + if obj.animation_data and obj.animation_data.action: + curr_action = obj.animation_data.action + copy_action = curr_action.copy() + + curr_frame_range = curr_action.frame_range + + starting_frames.append(curr_frame_range[0]) + ending_frames.append(curr_frame_range[1]) + else: + self.log.info("Object have no animation.") + continue + + asset_group_name = asset.name + asset.name = asset.get(AVALON_PROPERTY).get("asset_name") + + armature_name = obj.name + original_name = armature_name.split(':')[1] + obj.name = original_name + + object_action_pairs.append((obj, copy_action)) + original_actions.append(curr_action) + + # We compute the starting and ending frames + max_frame = min(starting_frames) + min_frame = max(ending_frames) + + # We bake the copy of the current action for each object + bpy_extras.anim_utils.bake_action_objects( + object_action_pairs, + frames=range(int(min_frame), int(max_frame)), + do_object=False, + do_clean=False + ) + + for o in bpy.data.objects: + o.select_set(False) + + asset.select_set(True) + obj.select_set(True) + fbx_filename = f"{n:03d}.fbx" + filepath = os.path.join(stagingdir, fbx_filename) + + override = plugin.create_blender_context( + active=asset, selected=[asset, obj]) + bpy.ops.export_scene.fbx( + override, + filepath=filepath, + use_active_collection=False, + use_selection=True, + bake_anim_use_nla_strips=False, + bake_anim_use_all_actions=False, + add_leaf_bones=False, + armature_nodetype='ROOT', + object_types={'EMPTY', 'ARMATURE'} + ) + obj.name = armature_name + asset.name = asset_group_name + asset.select_set(False) + obj.select_set(False) + + # We delete the baked action and set the original one back + for i in range(0, len(object_action_pairs)): + pair = object_action_pairs[i] + action = original_actions[i] + + if action: + pair[0].animation_data.action = action + + if pair[1]: + pair[1].user_clear() + bpy.data.actions.remove(pair[1]) + + return fbx_filename, n + 1 + + return None, n + def process(self, instance): # Define extract output file path stagingdir = self.staging_dir(instance) @@ -23,10 +119,16 @@ class ExtractLayout(openpype.api.Extractor): # Perform extraction self.log.info("Performing extraction..") + if "representations" not in instance.data: + instance.data["representations"] = [] + json_data = [] + fbx_files = [] asset_group = bpy.data.objects[str(instance)] + fbx_count = 0 + for asset in asset_group.children: metadata = asset.get(AVALON_PROPERTY) @@ -34,6 +136,7 @@ class ExtractLayout(openpype.api.Extractor): family = metadata["family"] self.log.debug("Parent: {}".format(parent)) + # Get blend reference blend = io.find_one( { "type": "representation", @@ -41,10 +144,39 @@ class ExtractLayout(openpype.api.Extractor): "name": "blend" }, projection={"_id": True}) - blend_id = blend["_id"] + blend_id = None + if blend: + blend_id = blend["_id"] + # Get fbx reference + fbx = io.find_one( + { + "type": "representation", + "parent": io.ObjectId(parent), + "name": "fbx" + }, + projection={"_id": True}) + fbx_id = None + if fbx: + fbx_id = fbx["_id"] + # Get abc reference + abc = io.find_one( + { + "type": "representation", + "parent": io.ObjectId(parent), + "name": "abc" + }, + projection={"_id": True}) + abc_id = None + if abc: + abc_id = abc["_id"] json_element = {} - json_element["reference"] = str(blend_id) + if blend_id: + json_element["reference"] = str(blend_id) + if fbx_id: + json_element["reference_fbx"] = str(fbx_id) + if abc_id: + json_element["reference_abc"] = str(abc_id) json_element["family"] = family json_element["instance_name"] = asset.name json_element["asset_name"] = metadata["asset_name"] @@ -67,6 +199,16 @@ class ExtractLayout(openpype.api.Extractor): "z": asset.scale.z } } + + # Extract the animation as well + if family == "rig": + f, n = self._export_animation( + asset, instance, stagingdir, fbx_count) + if f: + fbx_files.append(f) + json_element["animation"] = f + fbx_count = n + json_data.append(json_element) json_filename = "{}.json".format(instance.name) @@ -75,16 +217,32 @@ class ExtractLayout(openpype.api.Extractor): with open(json_path, "w+") as file: json.dump(json_data, fp=file, indent=2) - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { + json_representation = { 'name': 'json', 'ext': 'json', 'files': json_filename, "stagingDir": stagingdir, } - instance.data["representations"].append(representation) + instance.data["representations"].append(json_representation) + + self.log.debug(fbx_files) + + if len(fbx_files) == 1: + fbx_representation = { + 'name': 'fbx', + 'ext': '000.fbx', + 'files': fbx_files[0], + "stagingDir": stagingdir, + } + instance.data["representations"].append(fbx_representation) + elif len(fbx_files) > 1: + fbx_representation = { + 'name': 'fbx', + 'ext': 'fbx', + 'files': fbx_files, + "stagingDir": stagingdir, + } + instance.data["representations"].append(fbx_representation) self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + instance.name, json_representation) diff --git a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py index b81e1111ea..963ca1398f 100644 --- a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py +++ b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py @@ -1,5 +1,5 @@ import pyblish.api -import avalon.blender.workio +from openpype.hosts.blender.api.workio import save_file class IncrementWorkfileVersion(pyblish.api.ContextPlugin): @@ -9,7 +9,7 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin): label = "Increment Workfile Version" optional = True hosts = ["blender"] - families = ["animation", "model", "rig", "action"] + families = ["animation", "model", "rig", "action", "layout"] def process(self, context): @@ -20,6 +20,6 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin): path = context.data["currentFile"] filepath = version_up(path) - avalon.blender.workio.save_file(filepath, copy=False) + save_file(filepath, copy=False) self.log.info('Incrementing script version') diff --git a/openpype/hosts/blender/plugins/publish/validate_object_mode.py b/openpype/hosts/blender/plugins/publish/validate_object_mode.py index 1c82628c1c..90ef0b7c41 100644 --- a/openpype/hosts/blender/plugins/publish/validate_object_mode.py +++ b/openpype/hosts/blender/plugins/publish/validate_object_mode.py @@ -5,15 +5,15 @@ import openpype.hosts.blender.api.action class ValidateObjectIsInObjectMode(pyblish.api.InstancePlugin): - """Validate that the current object is in Object Mode.""" + """Validate that the objects in the instance are in Object Mode.""" order = pyblish.api.ValidatorOrder - 0.01 hosts = ["blender"] - families = ["model", "rig"] + families = ["model", "rig", "layout"] category = "geometry" - label = "Object is in Object Mode" + label = "Validate Object Mode" actions = [openpype.hosts.blender.api.action.SelectInvalidAction] - optional = True + optional = False @classmethod def get_invalid(cls, instance) -> List: diff --git a/openpype/hosts/blender/startup/init.py b/openpype/hosts/blender/startup/init.py deleted file mode 100644 index 4b4e48fedc..0000000000 --- a/openpype/hosts/blender/startup/init.py +++ /dev/null @@ -1,3 +0,0 @@ -from openpype.hosts.blender import api - -api.install() diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index a80366b9e4..e00f97d8ea 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -83,7 +83,7 @@ class ExtractSubsetResources(openpype.api.Extractor): staging_dir = self.staging_dir(instance) # add default preset type for thumbnail and reviewable video - # update them with settings and overide in case the same + # update them with settings and override in case the same # are found in there export_presets = deepcopy(self.default_presets) export_presets.update(self.export_presets_mapping) diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances.py b/openpype/hosts/houdini/plugins/publish/collect_instances.py index ac081ac297..12d118f0cc 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances.py @@ -74,6 +74,9 @@ class CollectInstances(pyblish.api.ContextPlugin): instance = context.create_instance(label) + # Include `families` using `family` data + instance.data["families"] = [instance.data["family"]] + instance[:] = [node] instance.data.update(data) diff --git a/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py b/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py index 78794acc97..113e1b0bcb 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py +++ b/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py @@ -37,5 +37,7 @@ class ExtractVDBCache(openpype.api.Extractor): "ext": "vdb", "files": output, "stagingDir": staging_dir, + "frameStart": instance.data["frameStart"], + "frameEnd": instance.data["frameEnd"], } instance.data["representations"].append(representation) diff --git a/openpype/hosts/maya/api/__init__.py b/openpype/hosts/maya/api/__init__.py index b25fd44217..0ad1c8ba29 100644 --- a/openpype/hosts/maya/api/__init__.py +++ b/openpype/hosts/maya/api/__init__.py @@ -218,12 +218,10 @@ def on_task_changed(*args): ) -def before_workfile_save(workfile_path): - if not workfile_path: - return - - workdir = os.path.dirname(workfile_path) - copy_workspace_mel(workdir) +def before_workfile_save(event): + workdir_path = event.workdir_path + if workdir_path: + copy_workspace_mel(workdir_path) class MayaDirmap(HostDirmap): diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 41528f20ba..b236fa7cdb 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -280,7 +280,7 @@ def shape_from_element(element): return node -def collect_animation_data(): +def collect_animation_data(fps=False): """Get the basic animation data Returns: @@ -291,7 +291,6 @@ def collect_animation_data(): # get scene values as defaults start = cmds.playbackOptions(query=True, animationStartTime=True) end = cmds.playbackOptions(query=True, animationEndTime=True) - fps = mel.eval('currentTimeUnitToFPS()') # build attributes data = OrderedDict() @@ -299,7 +298,9 @@ def collect_animation_data(): data["frameEnd"] = end data["handles"] = 0 data["step"] = 1.0 - data["fps"] = fps + + if fps: + data["fps"] = mel.eval('currentTimeUnitToFPS()') return data @@ -2853,3 +2854,27 @@ def set_colorspace(): cmds.colorManagementPrefs(e=True, renderingSpaceName=renderSpace) viewTransform = root_dict["viewTransform"] cmds.colorManagementPrefs(e=True, viewTransformName=viewTransform) + + +@contextlib.contextmanager +def root_parent(nodes): + # type: (list) -> list + """Context manager to un-parent provided nodes and return then back.""" + import pymel.core as pm # noqa + + node_parents = [] + for node in nodes: + n = pm.PyNode(node) + try: + root = pm.listRelatives(n, parent=1)[0] + except IndexError: + root = None + node_parents.append((n, root)) + try: + for node in node_parents: + node[0].setParent(world=True) + yield + finally: + for node in node_parents: + if node[1]: + node[0].setParent(node[1]) diff --git a/openpype/hosts/maya/plugins/create/create_review.py b/openpype/hosts/maya/plugins/create/create_review.py index 05b05be7a5..ae636ec691 100644 --- a/openpype/hosts/maya/plugins/create/create_review.py +++ b/openpype/hosts/maya/plugins/create/create_review.py @@ -22,7 +22,7 @@ class CreateReview(plugin.Creator): # get basic animation data : start / end / handles / steps data = OrderedDict(**self.data) - animation_data = lib.collect_animation_data() + animation_data = lib.collect_animation_data(fps=True) for key, value in animation_data.items(): data[key] = value diff --git a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py index db1684bbc8..9ad560ab7c 100644 --- a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py @@ -1,11 +1,58 @@ -from openpype.hosts.maya.api import plugin +# -*- coding: utf-8 -*- +"""Creator for Unreal Static Meshes.""" +from openpype.hosts.maya.api import plugin, lib +from avalon.api import Session +from openpype.api import get_project_settings +from maya import cmds # noqa class CreateUnrealStaticMesh(plugin.Creator): + """Unreal Static Meshes with collisions.""" name = "staticMeshMain" label = "Unreal - Static Mesh" family = "unrealStaticMesh" icon = "cube" + dynamic_subset_keys = ["asset"] def __init__(self, *args, **kwargs): + """Constructor.""" super(CreateUnrealStaticMesh, self).__init__(*args, **kwargs) + self._project_settings = get_project_settings( + Session["AVALON_PROJECT"]) + + @classmethod + def get_dynamic_data( + cls, variant, task_name, asset_id, project_name, host_name + ): + dynamic_data = super(CreateUnrealStaticMesh, cls).get_dynamic_data( + variant, task_name, asset_id, project_name, host_name + ) + dynamic_data["asset"] = Session.get("AVALON_ASSET") + + return dynamic_data + + def process(self): + with lib.undo_chunk(): + instance = super(CreateUnrealStaticMesh, self).process() + content = cmds.sets(instance, query=True) + + # empty set and process its former content + cmds.sets(content, rm=instance) + geometry_set = cmds.sets(name="geometry_SET", empty=True) + collisions_set = cmds.sets(name="collisions_SET", empty=True) + + cmds.sets([geometry_set, collisions_set], forceElement=instance) + + members = cmds.ls(content, long=True) or [] + children = cmds.listRelatives(members, allDescendents=True, + fullPath=True) or [] + children = cmds.ls(children, type="transform") + for node in children: + if cmds.listRelatives(node, type="shape"): + if [ + n for n in self.collision_prefixes + if node.startswith(n) + ]: + cmds.sets(node, forceElement=collisions_set) + else: + cmds.sets(node, forceElement=geometry_set) diff --git a/openpype/hosts/maya/plugins/create/create_vrayscene.py b/openpype/hosts/maya/plugins/create/create_vrayscene.py index f9d3c7b8f0..f2096d902e 100644 --- a/openpype/hosts/maya/plugins/create/create_vrayscene.py +++ b/openpype/hosts/maya/plugins/create/create_vrayscene.py @@ -4,6 +4,8 @@ import os import json import appdirs import requests +import six +import sys from maya import cmds import maya.app.renderSetup.model.renderSetup as renderSetup @@ -12,7 +14,15 @@ from openpype.hosts.maya.api import ( lib, plugin ) -from openpype.api import get_system_settings +from openpype.api import ( + get_system_settings, + get_project_settings +) + +from openpype.modules import ModulesManager + +from avalon.api import Session +from avalon.api import CreatorError class CreateVRayScene(plugin.Creator): @@ -22,11 +32,40 @@ class CreateVRayScene(plugin.Creator): family = "vrayscene" icon = "cubes" + _project_settings = None + def __init__(self, *args, **kwargs): """Entry.""" super(CreateVRayScene, self).__init__(*args, **kwargs) self._rs = renderSetup.instance() self.data["exportOnFarm"] = False + deadline_settings = get_system_settings()["modules"]["deadline"] + if not deadline_settings["enabled"]: + self.deadline_servers = {} + return + self._project_settings = get_project_settings( + Session["AVALON_PROJECT"]) + + try: + default_servers = deadline_settings["deadline_urls"] + project_servers = ( + self._project_settings["deadline"]["deadline_servers"] + ) + self.deadline_servers = { + k: default_servers[k] + for k in project_servers + if k in default_servers + } + + if not self.deadline_servers: + self.deadline_servers = default_servers + + except AttributeError: + # Handle situation were we had only one url for deadline. + manager = ModulesManager() + deadline_module = manager.modules_by_name["deadline"] + # get default deadline webservice url from deadline module + self.deadline_servers = deadline_module.deadline_urls def process(self): """Entry point.""" @@ -37,10 +76,10 @@ class CreateVRayScene(plugin.Creator): use_selection = self.options.get("useSelection") with lib.undo_chunk(): self._create_vray_instance_settings() - instance = super(CreateVRayScene, self).process() + self.instance = super(CreateVRayScene, self).process() index = 1 - namespace_name = "_{}".format(str(instance)) + namespace_name = "_{}".format(str(self.instance)) try: cmds.namespace(rm=namespace_name) except RuntimeError: @@ -48,10 +87,19 @@ class CreateVRayScene(plugin.Creator): pass while(cmds.namespace(exists=namespace_name)): - namespace_name = "_{}{}".format(str(instance), index) + namespace_name = "_{}{}".format(str(self.instance), index) index += 1 namespace = cmds.namespace(add=namespace_name) + + # add Deadline server selection list + if self.deadline_servers: + cmds.scriptJob( + attributeChange=[ + "{}.deadlineServers".format(self.instance), + self._deadline_webservice_changed + ]) + # create namespace with instance layers = self._rs.getRenderLayers() if use_selection: @@ -62,7 +110,7 @@ class CreateVRayScene(plugin.Creator): render_set = cmds.sets( n="{}:{}".format(namespace, layer.name())) sets.append(render_set) - cmds.sets(sets, forceElement=instance) + cmds.sets(sets, forceElement=self.instance) # if no render layers are present, create default one with # asterix selector @@ -71,6 +119,52 @@ class CreateVRayScene(plugin.Creator): collection = render_layer.createCollection("defaultCollection") collection.getSelector().setPattern('*') + def _deadline_webservice_changed(self): + """Refresh Deadline server dependent options.""" + # get selected server + from maya import cmds + webservice = self.deadline_servers[ + self.server_aliases[ + cmds.getAttr("{}.deadlineServers".format(self.instance)) + ] + ] + pools = self._get_deadline_pools(webservice) + cmds.deleteAttr("{}.primaryPool".format(self.instance)) + cmds.deleteAttr("{}.secondaryPool".format(self.instance)) + cmds.addAttr(self.instance, longName="primaryPool", + attributeType="enum", + enumName=":".join(pools)) + cmds.addAttr(self.instance, longName="secondaryPool", + attributeType="enum", + enumName=":".join(["-"] + pools)) + + def _get_deadline_pools(self, webservice): + # type: (str) -> list + """Get pools from Deadline. + Args: + webservice (str): Server url. + Returns: + list: Pools. + Throws: + RuntimeError: If deadline webservice is unreachable. + + """ + argument = "{}/api/pools?NamesOnly=true".format(webservice) + try: + response = self._requests_get(argument) + except requests.exceptions.ConnectionError as exc: + msg = 'Cannot connect to deadline web service' + self.log.error(msg) + six.reraise( + CreatorError, + CreatorError('{} - {}'.format(msg, exc)), + sys.exc_info()[2]) + if not response.ok: + self.log.warning("No pools retrieved") + return [] + + return response.json() + def _create_vray_instance_settings(self): # get pools pools = [] @@ -79,31 +173,29 @@ class CreateVRayScene(plugin.Creator): deadline_enabled = system_settings["deadline"]["enabled"] muster_enabled = system_settings["muster"]["enabled"] - deadline_url = system_settings["deadline"]["DEADLINE_REST_URL"] muster_url = system_settings["muster"]["MUSTER_REST_URL"] if deadline_enabled and muster_enabled: self.log.error( "Both Deadline and Muster are enabled. " "Cannot support both." ) - raise RuntimeError("Both Deadline and Muster are enabled") + raise CreatorError("Both Deadline and Muster are enabled") + + self.server_aliases = self.deadline_servers.keys() + self.data["deadlineServers"] = self.server_aliases if deadline_enabled: - argument = "{}/api/pools?NamesOnly=true".format(deadline_url) + # if default server is not between selected, use first one for + # initial list of pools. try: - response = self._requests_get(argument) - except requests.exceptions.ConnectionError as e: - msg = 'Cannot connect to deadline web service' - self.log.error(msg) - raise RuntimeError('{} - {}'.format(msg, e)) - if not response.ok: - self.log.warning("No pools retrieved") - else: - pools = response.json() - self.data["primaryPool"] = pools - # We add a string "-" to allow the user to not - # set any secondary pools - self.data["secondaryPool"] = ["-"] + pools + deadline_url = self.deadline_servers["default"] + except KeyError: + deadline_url = [ + self.deadline_servers[k] + for k in self.deadline_servers.keys() + ][0] + + pool_names = self._get_deadline_pools(deadline_url) if muster_enabled: self.log.info(">>> Loading Muster credentials ...") @@ -115,10 +207,10 @@ class CreateVRayScene(plugin.Creator): if e.startswith("401"): self.log.warning("access token expired") self._show_login() - raise RuntimeError("Access token expired") + raise CreatorError("Access token expired") except requests.exceptions.ConnectionError: self.log.error("Cannot connect to Muster API endpoint.") - raise RuntimeError("Cannot connect to {}".format(muster_url)) + raise CreatorError("Cannot connect to {}".format(muster_url)) pool_names = [] for pool in pools: self.log.info(" - pool: {}".format(pool["name"])) @@ -140,7 +232,7 @@ class CreateVRayScene(plugin.Creator): ``MUSTER_PASSWORD``, ``MUSTER_REST_URL`` is loaded from presets. Raises: - RuntimeError: If loaded credentials are invalid. + CreatorError: If loaded credentials are invalid. AttributeError: If ``MUSTER_REST_URL`` is not set. """ @@ -152,7 +244,7 @@ class CreateVRayScene(plugin.Creator): self._token = muster_json.get("token", None) if not self._token: self._show_login() - raise RuntimeError("Invalid access token for Muster") + raise CreatorError("Invalid access token for Muster") file.close() self.MUSTER_REST_URL = os.environ.get("MUSTER_REST_URL") if not self.MUSTER_REST_URL: @@ -162,7 +254,7 @@ class CreateVRayScene(plugin.Creator): """Get render pools from Muster. Raises: - Exception: If pool list cannot be obtained from Muster. + CreatorError: If pool list cannot be obtained from Muster. """ params = {"authToken": self._token} @@ -178,12 +270,12 @@ class CreateVRayScene(plugin.Creator): ("Cannot get pools from " "Muster: {}").format(response.status_code) ) - raise Exception("Cannot get pools from Muster") + raise CreatorError("Cannot get pools from Muster") try: pools = response.json()["ResponseData"]["pools"] except ValueError as e: self.log.error("Invalid response from Muster server {}".format(e)) - raise Exception("Invalid response from Muster server") + raise CreatorError("Invalid response from Muster server") return pools @@ -196,7 +288,7 @@ class CreateVRayScene(plugin.Creator): login_response = self._requests_get(api_url, timeout=1) if login_response.status_code != 200: self.log.error("Cannot show login form to Muster") - raise Exception("Cannot show login form to Muster") + raise CreatorError("Cannot show login form to Muster") def _requests_post(self, *args, **kwargs): """Wrap request post method. diff --git a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py index 80b453bd13..ed561e1131 100644 --- a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py +++ b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py @@ -2,6 +2,72 @@ from avalon import api from openpype.api import get_project_settings import os +from maya import cmds + +# List of 3rd Party Channels Mapping names for VRayVolumeGrid +# See: https://docs.chaosgroup.com/display/VRAY4MAYA/Input +# #Input-3rdPartyChannelsMapping +THIRD_PARTY_CHANNELS = { + 2: "Smoke", + 1: "Temperature", + 10: "Fuel", + 4: "Velocity.x", + 5: "Velocity.y", + 6: "Velocity.z", + 7: "Red", + 8: "Green", + 9: "Blue", + 14: "Wavelet Energy", + 19: "Wavelet.u", + 20: "Wavelet.v", + 21: "Wavelet.w", + # These are not in UI or documentation but V-Ray does seem to set these. + 15: "AdvectionOrigin.x", + 16: "AdvectionOrigin.y", + 17: "AdvectionOrigin.z", + +} + + +def _fix_duplicate_vvg_callbacks(): + """Workaround to kill duplicate VRayVolumeGrids attribute callbacks. + + This fixes a huge lag in Maya on switching 3rd Party Channels Mappings + or to different .vdb file paths because it spams an attribute changed + callback: `vvgUserChannelMappingsUpdateUI`. + + ChaosGroup bug ticket: 154-008-9890 + + Found with: + - Maya 2019.2 on Windows 10 + - V-Ray: V-Ray Next for Maya, update 1 version 4.12.01.00001 + + Bug still present in: + - Maya 2022.1 on Windows 10 + - V-Ray 5 for Maya, Update 2.1 (v5.20.01 from Dec 16 2021) + + """ + # todo(roy): Remove when new V-Ray release fixes duplicate calls + + jobs = cmds.scriptJob(listJobs=True) + + matched = set() + for entry in jobs: + # Remove the number + index, callback = entry.split(":", 1) + callback = callback.strip() + + # Detect whether it is a `vvgUserChannelMappingsUpdateUI` + # attribute change callback + if callback.startswith('"-runOnce" 1 "-attributeChange" "'): + if '"vvgUserChannelMappingsUpdateUI(' in callback: + if callback in matched: + # If we've seen this callback before then + # delete the duplicate callback + cmds.scriptJob(kill=int(index)) + else: + matched.add(callback) + class LoadVDBtoVRay(api.Loader): @@ -14,15 +80,24 @@ class LoadVDBtoVRay(api.Loader): def load(self, context, name, namespace, data): - from maya import cmds import avalon.maya.lib as lib from avalon.maya.pipeline import containerise + assert os.path.exists(self.fname), ( + "Path does not exist: %s" % self.fname + ) + try: family = context["representation"]["context"]["family"] except ValueError: family = "vdbcache" + # Ensure V-ray is loaded with the vrayvolumegrid + if not cmds.pluginInfo("vrayformaya", query=True, loaded=True): + cmds.loadPlugin("vrayformaya") + if not cmds.pluginInfo("vrayvolumegrid", query=True, loaded=True): + cmds.loadPlugin("vrayvolumegrid") + # Check if viewport drawing engine is Open GL Core (compat) render_engine = None compatible = "OpenGLCoreProfileCompat" @@ -30,13 +105,11 @@ class LoadVDBtoVRay(api.Loader): render_engine = cmds.optionVar(query="vp2RenderingEngine") if not render_engine or render_engine != compatible: - raise RuntimeError("Current scene's settings are incompatible." - "See Preferences > Display > Viewport 2.0 to " - "set the render engine to '%s'" % compatible) + self.log.warning("Current scene's settings are incompatible." + "See Preferences > Display > Viewport 2.0 to " + "set the render engine to '%s'" % compatible) asset = context['asset'] - version = context["version"] - asset_name = asset["name"] namespace = namespace or lib.unique_namespace( asset_name + "_", @@ -45,7 +118,7 @@ class LoadVDBtoVRay(api.Loader): ) # Root group - label = "{}:{}".format(namespace, name) + label = "{}:{}_VDB".format(namespace, name) root = cmds.group(name=label, empty=True) settings = get_project_settings(os.environ['AVALON_PROJECT']) @@ -55,20 +128,24 @@ class LoadVDBtoVRay(api.Loader): if c is not None: cmds.setAttr(root + ".useOutlinerColor", 1) cmds.setAttr(root + ".outlinerColor", - (float(c[0])/255), - (float(c[1])/255), - (float(c[2])/255) - ) + float(c[0]) / 255, + float(c[1]) / 255, + float(c[2]) / 255) - # Create VR + # Create VRayVolumeGrid grid_node = cmds.createNode("VRayVolumeGrid", - name="{}VVGShape".format(label), + name="{}Shape".format(label), parent=root) - # Set attributes - cmds.setAttr("{}.inFile".format(grid_node), self.fname, type="string") - cmds.setAttr("{}.inReadOffset".format(grid_node), - version["startFrames"]) + # Ensure .currentTime is connected to time1.outTime + cmds.connectAttr("time1.outTime", grid_node + ".currentTime") + + # Set path + self._set_path(grid_node, self.fname, show_preset_popup=True) + + # Lock the shape node so the user can't delete the transform/shape + # as if it was referenced + cmds.lockNode(grid_node, lock=True) nodes = [root, grid_node] self[:] = nodes @@ -79,3 +156,132 @@ class LoadVDBtoVRay(api.Loader): nodes=nodes, context=context, loader=self.__class__.__name__) + + def _set_path(self, grid_node, path, show_preset_popup=True): + + from openpype.hosts.maya.api.lib import attribute_values + from maya import cmds + + def _get_filename_from_folder(path): + # Using the sequence of .vdb files we check the frame range, etc. + # to set the filename with #### padding. + files = sorted(x for x in os.listdir(path) if x.endswith(".vdb")) + if not files: + raise RuntimeError("Couldn't find .vdb files in: %s" % path) + + if len(files) == 1: + # Ensure check for single file is also done in folder + fname = files[0] + else: + # Sequence + from avalon.vendor import clique + # todo: check support for negative frames as input + collections, remainder = clique.assemble(files) + assert len(collections) == 1, ( + "Must find a single image sequence, " + "found: %s" % (collections,) + ) + collection = collections[0] + + fname = collection.format('{head}{{padding}}{tail}') + padding = collection.padding + if padding == 0: + # Clique doesn't provide padding if the frame number never + # starts with a zero and thus has never any visual padding. + # So we fall back to the smallest frame number as padding. + padding = min(len(str(i)) for i in collection.indexes) + + # Supply frame/padding with # signs + padding_str = "#" * padding + fname = fname.format(padding=padding_str) + + return os.path.join(path, fname) + + # The path is either a single file or sequence in a folder so + # we do a quick lookup for our files + if os.path.isfile(path): + path = os.path.dirname(path) + path = _get_filename_from_folder(path) + + # Even when not applying a preset V-Ray will reset the 3rd Party + # Channels Mapping of the VRayVolumeGrid when setting the .inPath + # value. As such we try and preserve the values ourselves. + # Reported as ChaosGroup bug ticket: 154-011-2909  + # todo(roy): Remove when new V-Ray release preserves values + original_user_mapping = cmds.getAttr(grid_node + ".usrchmap") or "" + + # Workaround for V-Ray bug: fix lag on path change, see function + _fix_duplicate_vvg_callbacks() + + # Suppress preset pop-up if we want. + popup_attr = "{0}.inDontOfferPresets".format(grid_node) + popup = {popup_attr: not show_preset_popup} + with attribute_values(popup): + cmds.setAttr(grid_node + ".inPath", path, type="string") + + # Reapply the 3rd Party channels user mapping when no preset popup + # was shown to the user + if not show_preset_popup: + channels = cmds.getAttr(grid_node + ".usrchmapallch").split(";") + channels = set(channels) # optimize lookup + restored_mapping = "" + for entry in original_user_mapping.split(";"): + if not entry: + # Ignore empty entries + continue + + # If 3rd Party Channels selection channel still exists then + # add it again. + index, channel = entry.split(",") + attr = THIRD_PARTY_CHANNELS.get(int(index), + # Fallback for when a mapping + # was set that is not in the + # documentation + "???") + if channel in channels: + restored_mapping += entry + ";" + else: + self.log.warning("Can't preserve '%s' mapping due to " + "missing channel '%s' on node: " + "%s" % (attr, channel, grid_node)) + + if restored_mapping: + cmds.setAttr(grid_node + ".usrchmap", + restored_mapping, + type="string") + + def update(self, container, representation): + + path = api.get_representation_path(representation) + + # Find VRayVolumeGrid + members = cmds.sets(container['objectName'], query=True) + grid_nodes = cmds.ls(members, type="VRayVolumeGrid", long=True) + assert len(grid_nodes) > 0, "This is a bug" + + # Update the VRayVolumeGrid + for grid_node in grid_nodes: + self._set_path(grid_node, path=path, show_preset_popup=False) + + # Update container representation + cmds.setAttr(container["objectName"] + ".representation", + str(representation["_id"]), + type="string") + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + + # Get all members of the avalon container, ensure they are unlocked + # and delete everything + members = cmds.sets(container['objectName'], query=True) + cmds.lockNode(members, lock=False) + cmds.delete([container['objectName']] + members) + + # Clean up the namespace + try: + cmds.namespace(removeNamespace=container['namespace'], + deleteNamespaceContent=True) + except RuntimeError: + pass diff --git a/openpype/hosts/maya/plugins/load/load_vrayproxy.py b/openpype/hosts/maya/plugins/load/load_vrayproxy.py index e70f40bf5a..806cf1fd18 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayproxy.py +++ b/openpype/hosts/maya/plugins/load/load_vrayproxy.py @@ -17,8 +17,8 @@ from openpype.api import get_project_settings class VRayProxyLoader(api.Loader): """Load VRay Proxy with Alembic or VrayMesh.""" - families = ["vrayproxy"] - representations = ["vrmesh"] + families = ["vrayproxy", "model", "pointcache", "animation"] + representations = ["vrmesh", "abc"] label = "Import VRay Proxy" order = -10 diff --git a/openpype/hosts/maya/plugins/publish/clean_nodes.py b/openpype/hosts/maya/plugins/publish/clean_nodes.py new file mode 100644 index 0000000000..03995cdabe --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/clean_nodes.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +"""Cleanup leftover nodes.""" +from maya import cmds # noqa +import pyblish.api + + +class CleanNodesUp(pyblish.api.InstancePlugin): + """Cleans up the staging directory after a successful publish. + + This will also clean published renders and delete their parent directories. + + """ + + order = pyblish.api.IntegratorOrder + 10 + label = "Clean Nodes" + optional = True + active = True + + def process(self, instance): + if not instance.data.get("cleanNodes"): + self.log.info("Nothing to clean.") + return + + nodes_to_clean = instance.data.pop("cleanNodes", []) + self.log.info("Removing {} nodes".format(len(nodes_to_clean))) + for node in nodes_to_clean: + try: + cmds.delete(node) + except ValueError: + # object might be already deleted, don't complain about it + pass diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py index 5ab9643f4b..b1fb0542f2 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py @@ -4,25 +4,31 @@ import pyblish.api class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): - """Collect unreal static mesh + """Collect Unreal Static Mesh Ensures always only a single frame is extracted (current frame). This also sets correct FBX options for later extraction. - Note: - This is a workaround so that the `pype.model` family can use the - same pointcache extractor implementation as animation and pointcaches. - This always enforces the "current" frame to be published. - """ order = pyblish.api.CollectorOrder + 0.2 - label = "Collect Model Data" + label = "Collect Unreal Static Meshes" families = ["unrealStaticMesh"] def process(self, instance): # add fbx family to trigger fbx extractor instance.data["families"].append("fbx") + # take the name from instance (without the `S_` prefix) + instance.data["staticMeshCombinedName"] = instance.name[2:] + + geometry_set = [i for i in instance if i == "geometry_SET"] + instance.data["membersToCombine"] = cmds.sets( + geometry_set, query=True) + + collision_set = [i for i in instance if i == "collisions_SET"] + instance.data["collisionMembers"] = cmds.sets( + collision_set, query=True) + # set fbx overrides on instance instance.data["smoothingGroups"] = True instance.data["smoothMesh"] = True diff --git a/openpype/hosts/maya/plugins/publish/collect_vrayscene.py b/openpype/hosts/maya/plugins/publish/collect_vrayscene.py index e5c182c908..c1e5d388af 100644 --- a/openpype/hosts/maya/plugins/publish/collect_vrayscene.py +++ b/openpype/hosts/maya/plugins/publish/collect_vrayscene.py @@ -7,7 +7,7 @@ from maya import cmds import pyblish.api from avalon import api -from openpype.hosts.maya import lib +from openpype.hosts.maya.api import lib class CollectVrayScene(pyblish.api.InstancePlugin): diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx.py b/openpype/hosts/maya/plugins/publish/extract_fbx.py index 720a61b0a7..e4894f28cd 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx.py @@ -1,7 +1,9 @@ +# -*- coding: utf-8 -*- import os -from maya import cmds -import maya.mel as mel +from maya import cmds # noqa +import maya.mel as mel # noqa +from openpype.hosts.maya.api.lib import root_parent import pyblish.api import avalon.maya @@ -192,10 +194,7 @@ class ExtractFBX(openpype.api.Extractor): if isinstance(value, bool): value = str(value).lower() - template = "FBXExport{0} -v {1}" - if key == "UpAxis": - template = "FBXExport{0} {1}" - + template = "FBXExport{0} {1}" if key == "UpAxis" else "FBXExport{0} -v {1}" # noqa cmd = template.format(key, value) self.log.info(cmd) mel.eval(cmd) @@ -205,9 +204,16 @@ class ExtractFBX(openpype.api.Extractor): mel.eval("FBXExportGenerateLog -v false") # Export - with avalon.maya.maintained_selection(): - cmds.select(members, r=1, noExpand=True) - mel.eval('FBXExport -f "{}" -s'.format(path)) + if "unrealStaticMesh" in instance.data["families"]: + with avalon.maya.maintained_selection(): + with root_parent(members): + self.log.info("Un-parenting: {}".format(members)) + cmds.select(members, r=1, noExpand=True) + mel.eval('FBXExport -f "{}" -s'.format(path)) + else: + with avalon.maya.maintained_selection(): + cmds.select(members, r=1, noExpand=True) + mel.eval('FBXExport -f "{}" -s'.format(path)) if "representations" not in instance.data: instance.data["representations"] = [] diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py new file mode 100644 index 0000000000..32dc9d1d1c --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +"""Create Unreal Static Mesh data to be extracted as FBX.""" +import openpype.api +import pyblish.api +from maya import cmds # noqa + + +class ExtractUnrealStaticMesh(openpype.api.Extractor): + """Extract FBX from Maya. """ + + order = pyblish.api.ExtractorOrder - 0.1 + label = "Extract Unreal Static Mesh" + families = ["unrealStaticMesh"] + + def process(self, instance): + to_combine = instance.data.get("membersToCombine") + static_mesh_name = instance.data.get("staticMeshCombinedName") + self.log.info( + "merging {} into {}".format( + " + ".join(to_combine), static_mesh_name)) + duplicates = cmds.duplicate(to_combine, ic=True) + cmds.polyUnite( + *duplicates, + n=static_mesh_name, ch=False) + + if not instance.data.get("cleanNodes"): + instance.data["cleanNodes"] = [] + + instance.data["cleanNodes"].append(static_mesh_name) + instance.data["cleanNodes"] += duplicates + + instance.data["setMembers"] = [static_mesh_name] + instance.data["setMembers"] += instance.data["collisionMembers"] diff --git a/openpype/hosts/maya/plugins/publish/validate_assembly_name.py b/openpype/hosts/maya/plugins/publish/validate_assembly_name.py index 8f7a3dfaf9..02464b2302 100644 --- a/openpype/hosts/maya/plugins/publish/validate_assembly_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_assembly_name.py @@ -30,7 +30,8 @@ class ValidateAssemblyName(pyblish.api.InstancePlugin): descendants = cmds.listRelatives(content_instance, allDescendents=True, fullPath=True) or [] - descendants = cmds.ls(descendants, noIntermediate=True, long=True) + descendants = cmds.ls( + descendants, noIntermediate=True, type="transform") content_instance = list(set(content_instance + descendants)) assemblies = cmds.ls(content_instance, assemblies=True, long=True) diff --git a/openpype/hosts/maya/plugins/publish/validate_cycle_error.py b/openpype/hosts/maya/plugins/publish/validate_cycle_error.py new file mode 100644 index 0000000000..d4faf2e562 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_cycle_error.py @@ -0,0 +1,34 @@ +from maya import cmds + +import pyblish.api + +from avalon import maya + +import openpype.api +import openpype.hosts.maya.api.action + + +class ValidateCycleError(pyblish.api.InstancePlugin): + """Validate nodes produce no cycle errors.""" + + order = openpype.api.ValidateContentsOrder + 0.05 + label = "Cycle Errors" + hosts = ["maya"] + families = ["rig"] + actions = [openpype.hosts.maya.api.action.SelectInvalidAction] + optional = True + + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + raise RuntimeError("Nodes produce a cycle error: %s" % invalid) + + @classmethod + def get_invalid(cls, instance): + + with maya.maintained_selection(): + cmds.select(instance[:], noExpand=True) + plugs = cmds.cycleCheck(all=False, # check selection only + list=True) + invalid = cmds.ls(plugs, objectsOnly=True, long=True) + return invalid diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index 00f1fda2d3..901a2ec75e 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -1,27 +1,30 @@ # -*- coding: utf-8 -*- -from maya import cmds +from maya import cmds # noqa import pyblish.api import openpype.api import openpype.hosts.maya.api.action +from avalon.api import Session +from openpype.api import get_project_settings import re -class ValidateUnrealStaticmeshName(pyblish.api.InstancePlugin): +class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): """Validate name of Unreal Static Mesh Unreals naming convention states that staticMesh should start with `SM` - prefix - SM_[Name]_## (Eg. SM_sube_01). This plugin also validates other - types of meshes - collision meshes: + prefix - SM_[Name]_## (Eg. SM_sube_01).These prefixes can be configured + in Settings UI. This plugin also validates other types of + meshes - collision meshes: - UBX_[RenderMeshName]_##: + UBX_[RenderMeshName]*: Boxes are created with the Box objects type in Max or with the Cube polygonal primitive in Maya. You cannot move the vertices around or deform it in any way to make it something other than a rectangular prism, or else it will not work. - UCP_[RenderMeshName]_##: + UCP_[RenderMeshName]*: Capsules are created with the Capsule object type. The capsule does not need to have many segments (8 is a good number) at all because it is @@ -29,7 +32,7 @@ class ValidateUnrealStaticmeshName(pyblish.api.InstancePlugin): boxes, you should not move the individual vertices around. - USP_[RenderMeshName]_##: + USP_[RenderMeshName]*: Spheres are created with the Sphere object type. The sphere does not need to have many segments (8 is a good number) at all because it is @@ -37,7 +40,7 @@ class ValidateUnrealStaticmeshName(pyblish.api.InstancePlugin): boxes, you should not move the individual vertices around. - UCX_[RenderMeshName]_##: + UCX_[RenderMeshName]*: Convex objects can be any completely closed convex 3D shape. For example, a box can also be a convex object @@ -52,67 +55,86 @@ class ValidateUnrealStaticmeshName(pyblish.api.InstancePlugin): families = ["unrealStaticMesh"] label = "Unreal StaticMesh Name" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] - regex_mesh = r"SM_(?P.*)_(\d{2})" - regex_collision = r"((UBX)|(UCP)|(USP)|(UCX))_(?P.*)_(\d{2})" + regex_mesh = r"(?P.*))" + regex_collision = r"(?P.*)" @classmethod def get_invalid(cls, instance): - # find out if supplied transform is group or not - def is_group(groupName): - try: - children = cmds.listRelatives(groupName, children=True) - for child in children: - if not cmds.ls(child, transforms=True): - return False + invalid = [] + + project_settings = get_project_settings(Session["AVALON_PROJECT"]) + collision_prefixes = ( + project_settings + ["maya"] + ["create"] + ["CreateUnrealStaticMesh"] + ["collision_prefixes"] + ) + + combined_geometry_name = instance.data.get( + "staticMeshCombinedName", None) + if cls.validate_mesh: + # compile regex for testing names + regex_mesh = "{}{}".format( + ("_" + cls.static_mesh_prefix) or "", cls.regex_mesh + ) + sm_r = re.compile(regex_mesh) + if not sm_r.match(combined_geometry_name): + cls.log.error("Mesh doesn't comply with name validation.") return True - except Exception: + + if cls.validate_collision: + collision_set = instance.data.get("collisionMembers", None) + # soft-fail is there are no collision objects + if not collision_set: + cls.log.warning("No collision objects to validate.") return False - invalid = [] - content_instance = instance.data.get("setMembers", None) - if not content_instance: - cls.log.error("Instance has no nodes!") - return True - pass - descendants = cmds.listRelatives(content_instance, - allDescendents=True, - fullPath=True) or [] + regex_collision = "{}{}".format( + "(?P({}))_".format( + "|".join("{0}".format(p) for p in collision_prefixes) + ) or "", cls.regex_collision + ) - descendants = cmds.ls(descendants, noIntermediate=True, long=True) - trns = cmds.ls(descendants, long=False, type=('transform')) + cl_r = re.compile(regex_collision) - # filter out groups - filter = [node for node in trns if not is_group(node)] - - # compile regex for testing names - sm_r = re.compile(cls.regex_mesh) - cl_r = re.compile(cls.regex_collision) - - sm_names = [] - col_names = [] - for obj in filter: - sm_m = sm_r.match(obj) - if sm_m is None: - # test if it matches collision mesh - cl_r = sm_r.match(obj) - if cl_r is None: - cls.log.error("invalid mesh name on: {}".format(obj)) + for obj in collision_set: + cl_m = cl_r.match(obj) + if not cl_m: + cls.log.error("{} is invalid".format(obj)) invalid.append(obj) else: - col_names.append((cl_r.group("renderName"), obj)) - else: - sm_names.append(sm_m.group("renderName")) + expected_collision = "{}_{}".format( + cl_m.group("prefix"), + combined_geometry_name + ) - for c_mesh in col_names: - if c_mesh[0] not in sm_names: - cls.log.error(("collision name {} doesn't match any " - "static mesh names.").format(obj)) - invalid.append(c_mesh[1]) + if not obj.startswith(expected_collision): + + cls.log.error( + "Collision object name doesn't match " + "static mesh name" + ) + cls.log.error("{}_{} != {}_{}".format( + cl_m.group("prefix"), + cl_m.group("renderName"), + cl_m.group("prefix"), + combined_geometry_name, + )) + invalid.append(obj) return invalid def process(self, instance): + if not self.validate_mesh and not self.validate_collision: + self.log.info("Validation of both mesh and collision names" + "is disabled.") + return + + if not instance.data.get("collisionMembers", None): + self.log.info("There are no collision objects to validate") + return invalid = self.get_invalid(instance) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py index e264d04d9f..2a07c684da 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py @@ -4,7 +4,7 @@ import re import pyblish.api from openpype.lib import prepare_template_data -from openpype.lib.plugin_tools import parse_json +from openpype.lib.plugin_tools import parse_json, get_batch_asset_task_info from openpype.hosts.photoshop import api as photoshop @@ -29,26 +29,32 @@ class CollectRemoteInstances(pyblish.api.ContextPlugin): def process(self, context): self.log.info("CollectRemoteInstances") - self.log.info("mapping:: {}".format(self.color_code_mapping)) + self.log.debug("mapping:: {}".format(self.color_code_mapping)) # parse variant if used in webpublishing, comes from webpublisher batch batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") - variant = "Main" + task_data = None if batch_dir and os.path.exists(batch_dir): # TODO check if batch manifest is same as tasks manifests task_data = parse_json(os.path.join(batch_dir, "manifest.json")) - if not task_data: - raise ValueError( - "Cannot parse batch meta in {} folder".format(batch_dir)) - variant = task_data["variant"] + if not task_data: + raise ValueError( + "Cannot parse batch meta in {} folder".format(batch_dir)) + variant = task_data["variant"] stub = photoshop.stub() layers = stub.get_layers() + asset, task_name, task_type = get_batch_asset_task_info( + task_data["context"]) + + if not task_name: + task_name = task_type + instance_names = [] for layer in layers: - self.log.info("Layer:: {}".format(layer)) + self.log.debug("Layer:: {}".format(layer)) resolved_family, resolved_subset_template = self._resolve_mapping( layer ) @@ -57,7 +63,7 @@ class CollectRemoteInstances(pyblish.api.ContextPlugin): resolved_subset_template)) if not resolved_subset_template or not resolved_family: - self.log.debug("!!! Not marked, skip") + self.log.debug("!!! Not found family or template, skip") continue if layer.parents: @@ -68,8 +74,8 @@ class CollectRemoteInstances(pyblish.api.ContextPlugin): instance.append(layer) instance.data["family"] = resolved_family instance.data["publish"] = layer.visible - instance.data["asset"] = context.data["assetEntity"]["name"] - instance.data["task"] = context.data["taskType"] + instance.data["asset"] = asset + instance.data["task"] = task_name fill_pairs = { "variant": variant, @@ -114,7 +120,6 @@ class CollectRemoteInstances(pyblish.api.ContextPlugin): family_list.append(mapping["family"]) subset_name_list.append(mapping["subset_template_name"]) - if len(subset_name_list) > 1: self.log.warning("Multiple mappings found for '{}'". format(layer.name)) diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index 481285d603..20baa30847 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -71,8 +71,18 @@ class AnimationFBXLoader(api.Loader): if instance_name: automated = True - actor_name = 'PersistentLevel.' + instance_name - actor = unreal.EditorLevelLibrary.get_actor_reference(actor_name) + # Old method to get the actor + # actor_name = 'PersistentLevel.' + instance_name + # actor = unreal.EditorLevelLibrary.get_actor_reference(actor_name) + actors = unreal.EditorLevelLibrary.get_all_level_actors() + for a in actors: + if a.get_class().get_name() != "SkeletalMeshActor": + continue + if a.get_actor_label() == instance_name: + actor = a + break + if not actor: + raise Exception(f"Could not find actor {instance_name}") skeleton = actor.skeletal_mesh_component.skeletal_mesh.skeleton task.options.set_editor_property('skeleton', skeleton) @@ -173,20 +183,35 @@ class AnimationFBXLoader(api.Loader): task.set_editor_property('destination_name', name) task.set_editor_property('replace_existing', True) task.set_editor_property('automated', True) - task.set_editor_property('save', False) + task.set_editor_property('save', True) # set import options here task.options.set_editor_property( - 'automated_import_should_detect_type', True) + 'automated_import_should_detect_type', False) task.options.set_editor_property( - 'original_import_type', unreal.FBXImportType.FBXIT_ANIMATION) + 'original_import_type', unreal.FBXImportType.FBXIT_SKELETAL_MESH) + task.options.set_editor_property( + 'mesh_type_to_import', unreal.FBXImportType.FBXIT_ANIMATION) task.options.set_editor_property('import_mesh', False) task.options.set_editor_property('import_animations', True) + task.options.set_editor_property('override_full_name', True) - task.options.skeletal_mesh_import_data.set_editor_property( - 'import_content_type', - unreal.FBXImportContentType.FBXICT_SKINNING_WEIGHTS + task.options.anim_sequence_import_data.set_editor_property( + 'animation_length', + unreal.FBXAnimationLengthImportType.FBXALIT_EXPORTED_TIME ) + task.options.anim_sequence_import_data.set_editor_property( + 'import_meshes_in_bone_hierarchy', False) + task.options.anim_sequence_import_data.set_editor_property( + 'use_default_sample_rate', True) + task.options.anim_sequence_import_data.set_editor_property( + 'import_custom_attribute', True) + task.options.anim_sequence_import_data.set_editor_property( + 'import_bone_tracks', True) + task.options.anim_sequence_import_data.set_editor_property( + 'remove_redundant_keys', True) + task.options.anim_sequence_import_data.set_editor_property( + 'convert_scene', True) skeletal_mesh = unreal.EditorAssetLibrary.load_asset( container.get('namespace') + "/" + container.get('asset_name')) @@ -219,7 +244,7 @@ class AnimationFBXLoader(api.Loader): unreal.EditorAssetLibrary.delete_directory(path) asset_content = unreal.EditorAssetLibrary.list_assets( - parent_path, recursive=False + parent_path, recursive=False, include_folder=True ) if len(asset_content) == 0: diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py new file mode 100644 index 0000000000..19d0b74e3e --- /dev/null +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -0,0 +1,544 @@ +import os +import json +from pathlib import Path + +import unreal +from unreal import EditorAssetLibrary +from unreal import EditorLevelLibrary +from unreal import AssetToolsHelpers +from unreal import FBXImportType +from unreal import MathLibrary as umath + +from avalon import api, pipeline +from avalon.unreal import lib +from avalon.unreal import pipeline as unreal_pipeline + + +class LayoutLoader(api.Loader): + """Load Layout from a JSON file""" + + families = ["layout"] + representations = ["json"] + + label = "Load Layout" + icon = "code-fork" + color = "orange" + + def _get_asset_containers(self, path): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + asset_content = EditorAssetLibrary.list_assets( + path, recursive=True) + + asset_containers = [] + + # Get all the asset containers + for a in asset_content: + obj = ar.get_asset_by_object_path(a) + if obj.get_asset().get_class().get_name() == 'AssetContainer': + asset_containers.append(obj) + + return asset_containers + + def _get_fbx_loader(self, loaders, family): + name = "" + if family == 'rig': + name = "SkeletalMeshFBXLoader" + elif family == 'model': + name = "StaticMeshFBXLoader" + elif family == 'camera': + name = "CameraLoader" + + if name == "": + return None + + for loader in loaders: + if loader.__name__ == name: + return loader + + return None + + def _get_abc_loader(self, loaders, family): + name = "" + if family == 'rig': + name = "SkeletalMeshAlembicLoader" + elif family == 'model': + name = "StaticMeshAlembicLoader" + + if name == "": + return None + + for loader in loaders: + if loader.__name__ == name: + return loader + + return None + + def _process_family(self, assets, classname, transform, inst_name=None): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + actors = [] + + for asset in assets: + obj = ar.get_asset_by_object_path(asset).get_asset() + if obj.get_class().get_name() == classname: + actor = EditorLevelLibrary.spawn_actor_from_object( + obj, + transform.get('translation') + ) + if inst_name: + try: + # Rename method leads to crash + # actor.rename(name=inst_name) + + # The label works, although it make it slightly more + # complicated to check for the names, as we need to + # loop through all the actors in the level + actor.set_actor_label(inst_name) + except Exception as e: + print(e) + actor.set_actor_rotation(unreal.Rotator( + umath.radians_to_degrees( + transform.get('rotation').get('x')), + -umath.radians_to_degrees( + transform.get('rotation').get('y')), + umath.radians_to_degrees( + transform.get('rotation').get('z')), + ), False) + actor.set_actor_scale3d(transform.get('scale')) + + actors.append(actor) + + return actors + + def _import_animation( + self, asset_dir, path, instance_name, skeleton, actors_dict, + animation_file): + anim_file = Path(animation_file) + anim_file_name = anim_file.with_suffix('') + + anim_path = f"{asset_dir}/animations/{anim_file_name}" + + # Import animation + task = unreal.AssetImportTask() + task.options = unreal.FbxImportUI() + + task.set_editor_property( + 'filename', str(path.with_suffix(f".{animation_file}"))) + task.set_editor_property('destination_path', anim_path) + task.set_editor_property( + 'destination_name', f"{instance_name}_animation") + task.set_editor_property('replace_existing', False) + task.set_editor_property('automated', True) + task.set_editor_property('save', False) + + # set import options here + task.options.set_editor_property( + 'automated_import_should_detect_type', False) + task.options.set_editor_property( + 'original_import_type', FBXImportType.FBXIT_SKELETAL_MESH) + task.options.set_editor_property( + 'mesh_type_to_import', FBXImportType.FBXIT_ANIMATION) + task.options.set_editor_property('import_mesh', False) + task.options.set_editor_property('import_animations', True) + task.options.set_editor_property('override_full_name', True) + task.options.set_editor_property('skeleton', skeleton) + + task.options.anim_sequence_import_data.set_editor_property( + 'animation_length', + unreal.FBXAnimationLengthImportType.FBXALIT_EXPORTED_TIME + ) + task.options.anim_sequence_import_data.set_editor_property( + 'import_meshes_in_bone_hierarchy', False) + task.options.anim_sequence_import_data.set_editor_property( + 'use_default_sample_rate', True) + task.options.anim_sequence_import_data.set_editor_property( + 'import_custom_attribute', True) + task.options.anim_sequence_import_data.set_editor_property( + 'import_bone_tracks', True) + task.options.anim_sequence_import_data.set_editor_property( + 'remove_redundant_keys', True) + task.options.anim_sequence_import_data.set_editor_property( + 'convert_scene', True) + + AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + + asset_content = unreal.EditorAssetLibrary.list_assets( + anim_path, recursive=False, include_folder=False + ) + + animation = None + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + imported_asset_data = unreal.EditorAssetLibrary.find_asset_data(a) + imported_asset = unreal.AssetRegistryHelpers.get_asset( + imported_asset_data) + if imported_asset.__class__ == unreal.AnimSequence: + animation = imported_asset + break + + if animation: + actor = None + if actors_dict.get(instance_name): + for a in actors_dict.get(instance_name): + if a.get_class().get_name() == 'SkeletalMeshActor': + actor = a + break + + animation.set_editor_property('enable_root_motion', True) + actor.skeletal_mesh_component.set_editor_property( + 'animation_mode', unreal.AnimationMode.ANIMATION_SINGLE_NODE) + actor.skeletal_mesh_component.animation_data.set_editor_property( + 'anim_to_play', animation) + + def _process(self, libpath, asset_dir, loaded=None): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + with open(libpath, "r") as fp: + data = json.load(fp) + + all_loaders = api.discover(api.Loader) + + if not loaded: + loaded = [] + + path = Path(libpath) + + skeleton_dict = {} + actors_dict = {} + + for element in data: + reference = None + if element.get('reference_fbx'): + reference = element.get('reference_fbx') + elif element.get('reference_abc'): + reference = element.get('reference_abc') + + # If reference is None, this element is skipped, as it cannot be + # imported in Unreal + if not reference: + continue + + instance_name = element.get('instance_name') + + skeleton = None + + if reference not in loaded: + loaded.append(reference) + + family = element.get('family') + loaders = api.loaders_from_representation( + all_loaders, reference) + + loader = None + + if reference == element.get('reference_fbx'): + loader = self._get_fbx_loader(loaders, family) + elif reference == element.get('reference_abc'): + loader = self._get_abc_loader(loaders, family) + + if not loader: + continue + + options = { + "asset_dir": asset_dir + } + + assets = api.load( + loader, + reference, + namespace=instance_name, + options=options + ) + + instances = [ + item for item in data + if (item.get('reference_fbx') == reference or + item.get('reference_abc') == reference)] + + for instance in instances: + transform = instance.get('transform') + inst = instance.get('instance_name') + + actors = [] + + if family == 'model': + actors = self._process_family( + assets, 'StaticMesh', transform, inst) + elif family == 'rig': + actors = self._process_family( + assets, 'SkeletalMesh', transform, inst) + actors_dict[inst] = actors + + if family == 'rig': + # Finds skeleton among the imported assets + for asset in assets: + obj = ar.get_asset_by_object_path(asset).get_asset() + if obj.get_class().get_name() == 'Skeleton': + skeleton = obj + if skeleton: + break + + if skeleton: + skeleton_dict[reference] = skeleton + else: + skeleton = skeleton_dict.get(reference) + + animation_file = element.get('animation') + + if animation_file and skeleton: + self._import_animation( + asset_dir, path, instance_name, skeleton, + actors_dict, animation_file) + + def _remove_family(self, assets, components, classname, propname): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + objects = [] + for a in assets: + obj = ar.get_asset_by_object_path(a) + if obj.get_asset().get_class().get_name() == classname: + objects.append(obj) + for obj in objects: + for comp in components: + if comp.get_editor_property(propname) == obj.get_asset(): + comp.get_owner().destroy_actor() + + def _remove_actors(self, path): + asset_containers = self._get_asset_containers(path) + + # Get all the static and skeletal meshes components in the level + components = EditorLevelLibrary.get_all_level_actors_components() + static_meshes_comp = [ + c for c in components + if c.get_class().get_name() == 'StaticMeshComponent'] + skel_meshes_comp = [ + c for c in components + if c.get_class().get_name() == 'SkeletalMeshComponent'] + + # For all the asset containers, get the static and skeletal meshes. + # Then, check the components in the level and destroy the matching + # actors. + for asset_container in asset_containers: + package_path = asset_container.get_editor_property('package_path') + family = EditorAssetLibrary.get_metadata_tag( + asset_container.get_asset(), 'family') + assets = EditorAssetLibrary.list_assets( + str(package_path), recursive=False) + if family == 'model': + self._remove_family( + assets, static_meshes_comp, 'StaticMesh', 'static_mesh') + elif family == 'rig': + self._remove_family( + assets, skel_meshes_comp, 'SkeletalMesh', 'skeletal_mesh') + + def load(self, context, name, namespace, options): + """ + Load and containerise representation into Content Browser. + + This is two step process. First, import FBX to temporary path and + then call `containerise()` on it - this moves all content to new + directory and then it will create AssetContainer there and imprint it + with metadata. This will mark this path as container. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. This is not used + now, data are imprinted by `containerise()`. + + Returns: + list(str): list of container content + """ + # Create directory for asset and avalon container + root = "/Game/Avalon/Assets" + asset = context.get('asset').get('name') + suffix = "_CON" + if asset: + asset_name = "{}_{}".format(asset, name) + else: + asset_name = "{}".format(name) + + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + "{}/{}/{}".format(root, asset, name), suffix="") + + container_name += suffix + + EditorAssetLibrary.make_directory(asset_dir) + + self._process(self.fname, asset_dir) + + # Create Asset Container + lib.create_avalon_container( + container=container_name, path=asset_dir) + + data = { + "schema": "openpype:container-2.0", + "id": pipeline.AVALON_CONTAINER_ID, + "asset": asset, + "namespace": asset_dir, + "container_name": container_name, + "asset_name": asset_name, + "loader": str(self.__class__.__name__), + "representation": context["representation"]["_id"], + "parent": context["representation"]["parent"], + "family": context["representation"]["context"]["family"] + } + unreal_pipeline.imprint( + "{}/{}".format(asset_dir, container_name), data) + + asset_content = EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=False) + + for a in asset_content: + EditorAssetLibrary.save_asset(a) + + return asset_content + + def update(self, container, representation): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + source_path = api.get_representation_path(representation) + destination_path = container["namespace"] + libpath = Path(api.get_representation_path(representation)) + + self._remove_actors(destination_path) + + # Delete old animations + anim_path = f"{destination_path}/animations/" + EditorAssetLibrary.delete_directory(anim_path) + + with open(source_path, "r") as fp: + data = json.load(fp) + + references = [e.get('reference_fbx') for e in data] + asset_containers = self._get_asset_containers(destination_path) + loaded = [] + + # Delete all the assets imported with the previous version of the + # layout, if they're not in the new layout. + for asset_container in asset_containers: + if asset_container.get_editor_property( + 'asset_name') == container["objectName"]: + continue + ref = EditorAssetLibrary.get_metadata_tag( + asset_container.get_asset(), 'representation') + ppath = asset_container.get_editor_property('package_path') + + if ref not in references: + # If the asset is not in the new layout, delete it. + # Also check if the parent directory is empty, and delete that + # as well, if it is. + EditorAssetLibrary.delete_directory(ppath) + + parent = os.path.dirname(str(ppath)) + parent_content = EditorAssetLibrary.list_assets( + parent, recursive=False, include_folder=True + ) + + if len(parent_content) == 0: + EditorAssetLibrary.delete_directory(parent) + else: + # If the asset is in the new layout, search the instances in + # the JSON file, and create actors for them. + + actors_dict = {} + skeleton_dict = {} + + for element in data: + reference = element.get('reference_fbx') + instance_name = element.get('instance_name') + + skeleton = None + + if reference == ref and ref not in loaded: + loaded.append(ref) + + family = element.get('family') + + assets = EditorAssetLibrary.list_assets( + ppath, recursive=True, include_folder=False) + + instances = [ + item for item in data + if item.get('reference_fbx') == reference] + + for instance in instances: + transform = instance.get('transform') + inst = instance.get('instance_name') + + actors = [] + + if family == 'model': + actors = self._process_family( + assets, 'StaticMesh', transform, inst) + elif family == 'rig': + actors = self._process_family( + assets, 'SkeletalMesh', transform, inst) + actors_dict[inst] = actors + + if family == 'rig': + # Finds skeleton among the imported assets + for asset in assets: + obj = ar.get_asset_by_object_path( + asset).get_asset() + if obj.get_class().get_name() == 'Skeleton': + skeleton = obj + if skeleton: + break + + if skeleton: + skeleton_dict[reference] = skeleton + else: + skeleton = skeleton_dict.get(reference) + + animation_file = element.get('animation') + + if animation_file and skeleton: + self._import_animation( + destination_path, libpath, + instance_name, skeleton, + actors_dict, animation_file) + + self._process(source_path, destination_path, loaded) + + container_path = "{}/{}".format(container["namespace"], + container["objectName"]) + # update metadata + unreal_pipeline.imprint( + container_path, + { + "representation": str(representation["_id"]), + "parent": str(representation["parent"]) + }) + + asset_content = EditorAssetLibrary.list_assets( + destination_path, recursive=True, include_folder=False) + + for a in asset_content: + EditorAssetLibrary.save_asset(a) + + def remove(self, container): + """ + First, destroy all actors of the assets to be removed. Then, deletes + the asset's directory. + """ + path = container["namespace"] + parent_path = os.path.dirname(path) + + self._remove_actors(path) + + EditorAssetLibrary.delete_directory(path) + + asset_content = EditorAssetLibrary.list_assets( + parent_path, recursive=False, include_folder=True + ) + + if len(asset_content) == 0: + EditorAssetLibrary.delete_directory(parent_path) diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index 7f6e31618a..c7d095aa21 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -15,7 +15,7 @@ class SkeletalMeshFBXLoader(api.Loader): icon = "cube" color = "orange" - def load(self, context, name, namespace, data): + def load(self, context, name, namespace, options): """ Load and containerise representation into Content Browser. @@ -40,6 +40,8 @@ class SkeletalMeshFBXLoader(api.Loader): # Create directory for asset and avalon container root = "/Game/Avalon/Assets" + if options and options.get("asset_dir"): + root = options["asset_dir"] asset = context.get('asset').get('name') suffix = "_CON" if asset: diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py index d25f84ea69..510c4331ad 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py @@ -40,7 +40,7 @@ class StaticMeshFBXLoader(api.Loader): return task - def load(self, context, name, namespace, data): + def load(self, context, name, namespace, options): """ Load and containerise representation into Content Browser. @@ -65,6 +65,8 @@ class StaticMeshFBXLoader(api.Loader): # Create directory for asset and avalon container root = "/Game/Avalon/Assets" + if options and options.get("asset_dir"): + root = options["asset_dir"] asset = context.get('asset').get('name') suffix = "_CON" if asset: diff --git a/openpype/hosts/webpublisher/plugins/publish/extract_thumbnail.py b/openpype/hosts/webpublisher/plugins/publish/extract_thumbnail.py new file mode 100644 index 0000000000..cb6ed8481c --- /dev/null +++ b/openpype/hosts/webpublisher/plugins/publish/extract_thumbnail.py @@ -0,0 +1,139 @@ +import os +import shutil + +import pyblish.api +from openpype.lib import ( + get_ffmpeg_tool_path, + + run_subprocess, + + get_transcode_temp_directory, + convert_for_ffmpeg, + should_convert_for_ffmpeg +) + + +class ExtractThumbnail(pyblish.api.InstancePlugin): + """Create jpg thumbnail from input using ffmpeg.""" + + label = "Extract Thumbnail" + order = pyblish.api.ExtractorOrder + families = [ + "render", + "image" + ] + hosts = ["webpublisher"] + targets = ["filespublish"] + + def process(self, instance): + self.log.info("subset {}".format(instance.data['subset'])) + + 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] + + stagingdir = os.path.normpath(repre["stagingDir"]) + + full_input_path = os.path.join(stagingdir, input_file) + self.log.info("Input filepath: {}".format(full_input_path)) + + 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 + + # 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] + while filename.endswith("."): + filename = filename[:-1] + thumbnail_filename = filename + "_thumbnail.jpg" + full_output_path = os.path.join(stagingdir, thumbnail_filename) + + self.log.info("output {}".format(full_output_path)) + + ffmpeg_args = [ + get_ffmpeg_tool_path("ffmpeg"), + "-y", + "-i", full_input_path, + "-vframes", "1", + full_output_path + ] + + # run subprocess + self.log.debug("{}".format(" ".join(ffmpeg_args))) + try: # temporary until oiiotool is supported cross platform + run_subprocess( + ffmpeg_args, logger=self.log + ) + except RuntimeError as exp: + if "Compression" in str(exp): + self.log.debug( + "Unsupported compression on input files. Skipping!!!" + ) + return + self.log.warning("Conversion crashed", exc_info=True) + raise + + new_repre = { + "name": "thumbnail", + "ext": "jpg", + "files": thumbnail_filename, + "stagingDir": stagingdir, + "thumbnail": True, + "tags": ["thumbnail"] + } + + # adding representation + self.log.debug("Adding: {}".format(new_repre)) + instance.data["representations"].append(new_repre) + + # Cleanup temp folder + if convert_dir is not None and os.path.exists(convert_dir): + shutil.rmtree(convert_dir) + + def _get_filtered_repres(self, instance): + filtered_repres = [] + repres = instance.data.get("representations") or [] + for repre in repres: + self.log.debug(repre) + tags = repre.get("tags") or [] + # Skip instance if already has thumbnail representation + if "thumbnail" in tags: + return [] + + if "review" not in tags: + 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/lib/__init__.py b/openpype/lib/__init__.py index 1c8f7a57af..7dd9a8793b 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -175,7 +175,8 @@ from .openpype_version import ( get_expected_version, is_running_from_build, is_running_staging, - is_current_version_studio_latest + is_current_version_studio_latest, + is_current_version_higher_than_expected ) terminal = Terminal diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 0e1f44391e..a704c3ae68 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1490,6 +1490,7 @@ def _prepare_last_workfile(data, workdir): import avalon.api log = data["log"] + _workdir_data = data.get("workdir_data") if not _workdir_data: log.info( @@ -1503,9 +1504,15 @@ def _prepare_last_workfile(data, workdir): project_name = data["project_name"] task_name = data["task_name"] task_type = data["task_type"] - start_last_workfile = should_start_last_workfile( - project_name, app.host_name, task_name, task_type - ) + + start_last_workfile = data.get("start_last_workfile") + if start_last_workfile is None: + start_last_workfile = should_start_last_workfile( + project_name, app.host_name, task_name, task_type + ) + else: + log.info("Opening of last workfile was disabled by user") + data["start_last_workfile"] = start_last_workfile workfile_startup = should_workfile_tool_start( diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py index 201bf646e9..d547d34755 100644 --- a/openpype/lib/openpype_version.py +++ b/openpype/lib/openpype_version.py @@ -195,3 +195,32 @@ def is_current_version_studio_latest(): expected_version = get_expected_version() # Check if current version is expected version return current_version == expected_version + + +def is_current_version_higher_than_expected(): + """Is current OpenPype version higher than version defined by studio. + + Returns: + None: Can't determine. e.g. when running from code or the build is + too old. + bool: True when is higher than studio version. + """ + output = None + # Skip if is not running from build or build does not support version + # control or path to folder with zip files is not accessible + if ( + not is_running_from_build() + or not op_version_control_available() + or not openpype_path_is_accessible() + ): + return output + + # Get OpenPypeVersion class + OpenPypeVersion = get_OpenPypeVersion() + # Convert current version to OpenPypeVersion object + current_version = OpenPypeVersion(version=get_openpype_version()) + + # Get expected version (from settings) + expected_version = get_expected_version() + # Check if current version is expected version + return current_version > expected_version diff --git a/openpype/lib/pype_info.py b/openpype/lib/pype_info.py index 848a505187..8370ecc88f 100644 --- a/openpype/lib/pype_info.py +++ b/openpype/lib/pype_info.py @@ -10,11 +10,12 @@ from .execute import get_openpype_execute_args from .local_settings import get_local_site_id from .openpype_version import ( is_running_from_build, - get_openpype_version + get_openpype_version, + get_build_version ) -def get_pype_info(): +def get_openpype_info(): """Information about currently used Pype process.""" executable_args = get_openpype_execute_args() if is_running_from_build(): @@ -23,6 +24,7 @@ def get_pype_info(): version_type = "code" return { + "build_verison": get_build_version(), "version": get_openpype_version(), "version_type": version_type, "executable": executable_args[-1], @@ -51,7 +53,7 @@ def get_workstation_info(): def get_all_current_info(): """All information about current process in one dictionary.""" return { - "pype": get_pype_info(), + "pype": get_openpype_info(), "workstation": get_workstation_info(), "env": os.environ.copy(), "local_settings": get_local_settings() diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index a5d4153b2a..4c2cf93dfa 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -34,11 +34,17 @@ def get_vendor_bin_path(bin_app): def get_oiio_tools_path(tool="oiiotool"): """Path to vendorized OpenImageIO tool executables. + On Window it adds .exe extension if missing from tool argument. + Args: tool (string): Tool name (oiiotool, maketx, ...). Default is "oiiotool". """ oiio_dir = get_vendor_bin_path("oiio") + if platform.system().lower() == "windows" and not tool.lower().endswith( + ".exe" + ): + tool = "{}.exe".format(tool) return os.path.join(oiio_dir, tool) diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/lib.py b/openpype/modules/default_modules/ftrack/ftrack_server/lib.py index e80d6a3a6b..f8319b67d4 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_server/lib.py +++ b/openpype/modules/default_modules/ftrack/ftrack_server/lib.py @@ -164,7 +164,7 @@ class ProcessEventHub(SocketBaseEventHub): sys.exit(0) def wait(self, duration=None): - """Overriden wait + """Overridden wait Event are loaded from Mongo DB when queue is empty. Handled event is set as processed in Mongo DB. """ diff --git a/openpype/modules/default_modules/sync_server/providers/dropbox.py b/openpype/modules/default_modules/sync_server/providers/dropbox.py index 90d7d44bb8..6200b12bb2 100644 --- a/openpype/modules/default_modules/sync_server/providers/dropbox.py +++ b/openpype/modules/default_modules/sync_server/providers/dropbox.py @@ -95,7 +95,7 @@ class DropboxHandler(AbstractProvider): "key": "acting_as_member", "label": "Acting As Member" }, - # roots could be overriden only on Project level, User cannot + # roots could be overridden only on Project level, User cannot { "key": "root", "label": "Roots", diff --git a/openpype/modules/default_modules/sync_server/providers/gdrive.py b/openpype/modules/default_modules/sync_server/providers/gdrive.py index d43e2b3d61..0b586613b5 100644 --- a/openpype/modules/default_modules/sync_server/providers/gdrive.py +++ b/openpype/modules/default_modules/sync_server/providers/gdrive.py @@ -119,7 +119,7 @@ class GDriveHandler(AbstractProvider): # {platform} tells that value is multiplatform and only specific OS # should be returned editable = [ - # credentials could be overriden on Project or User level + # credentials could be overridden on Project or User level { "type": "path", "key": "credentials_url", @@ -127,7 +127,7 @@ class GDriveHandler(AbstractProvider): "multiplatform": True, "placeholder": "Credentials url" }, - # roots could be overriden only on Project leve, User cannot + # roots could be overridden only on Project level, User cannot { "key": "root", "label": "Roots", @@ -414,7 +414,7 @@ class GDriveHandler(AbstractProvider): def delete_folder(self, path, force=False): """ Deletes folder on GDrive. Checks if folder contains any files or - subfolders. In that case raises error, could be overriden by + subfolders. In that case raises error, could be overridden by 'force' argument. In that case deletes folder on 'path' and all its children. diff --git a/openpype/modules/default_modules/sync_server/providers/sftp.py b/openpype/modules/default_modules/sync_server/providers/sftp.py index 1585b326bd..49b87b14ec 100644 --- a/openpype/modules/default_modules/sync_server/providers/sftp.py +++ b/openpype/modules/default_modules/sync_server/providers/sftp.py @@ -97,7 +97,7 @@ class SFTPHandler(AbstractProvider): # {platform} tells that value is multiplatform and only specific OS # should be returned editable = [ - # credentials could be overriden on Project or User level + # credentials could be overridden on Project or User level { 'key': "sftp_host", 'label': "SFTP host name", @@ -129,7 +129,7 @@ class SFTPHandler(AbstractProvider): 'label': "SFTP user ssh key password", 'type': 'text' }, - # roots could be overriden only on Project leve, User cannot + # roots could be overridden only on Project level, User cannot { "key": "root", "label": "Roots", diff --git a/openpype/modules/default_modules/sync_server/sync_server_module.py b/openpype/modules/default_modules/sync_server/sync_server_module.py index 500203f3fc..caf58503f1 100644 --- a/openpype/modules/default_modules/sync_server/sync_server_module.py +++ b/openpype/modules/default_modules/sync_server/sync_server_module.py @@ -1073,7 +1073,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): """ Returns settings for 'studio' and user's local site - Returns base values from setting, not overriden by Local Settings, + Returns base values from setting, not overridden by Local Settings, eg. value used to push TO LS not to get actual value for syncing. """ if not project_name: diff --git a/openpype/modules/interfaces.py b/openpype/modules/interfaces.py index e6e84a0d42..7c301c15b4 100644 --- a/openpype/modules/interfaces.py +++ b/openpype/modules/interfaces.py @@ -115,7 +115,7 @@ class ITrayAction(ITrayModule): Add action to tray menu which will trigger `on_action_trigger`. It is expected to be used for showing tools. - Methods `tray_start`, `tray_exit` and `connect_with_modules` are overriden + Methods `tray_start`, `tray_exit` and `connect_with_modules` are overridden as it's not expected that action will use them. But it is possible if necessary. """ diff --git a/openpype/modules/job_queue/job_server/workers_rpc_route.py b/openpype/modules/job_queue/job_server/workers_rpc_route.py index 0800ca0d4d..e3c67fb3c3 100644 --- a/openpype/modules/job_queue/job_server/workers_rpc_route.py +++ b/openpype/modules/job_queue/job_server/workers_rpc_route.py @@ -72,7 +72,7 @@ class WorkerRpc(JsonRpc): self._job_queue.remove_worker(worker) async def handle_websocket_request(self, http_request): - """Overide this method to catch CLOSING messages.""" + """Override this method to catch CLOSING messages.""" http_request.msg_id = 0 http_request.pending = {} diff --git a/openpype/pipeline/lib/__init__.py b/openpype/pipeline/lib/__init__.py index 1bb65be79b..e2c15cbd2d 100644 --- a/openpype/pipeline/lib/__init__.py +++ b/openpype/pipeline/lib/__init__.py @@ -1,3 +1,8 @@ +from .events import ( + BaseEvent, + BeforeWorkfileSave +) + from .attribute_definitions import ( AbtractAttrDef, UnknownDef, @@ -9,6 +14,9 @@ from .attribute_definitions import ( __all__ = ( + "BaseEvent", + "BeforeWorkfileSave", + "AbtractAttrDef", "UnknownDef", "NumberDef", diff --git a/openpype/pipeline/lib/events.py b/openpype/pipeline/lib/events.py new file mode 100644 index 0000000000..05dea20e8c --- /dev/null +++ b/openpype/pipeline/lib/events.py @@ -0,0 +1,51 @@ +"""Events holding data about specific event.""" + + +# Inherit from 'object' for Python 2 hosts +class BaseEvent(object): + """Base event object. + + Can be used to anything because data are not much specific. Only required + argument is topic which defines why event is happening and may be used for + filtering. + + Arg: + topic (str): Identifier of event. + data (Any): Data specific for event. Dictionary is recommended. + """ + _data = {} + + def __init__(self, topic, data=None): + self._topic = topic + if data is None: + data = {} + self._data = data + + @property + def data(self): + return self._data + + @property + def topic(self): + return self._topic + + @classmethod + def emit(cls, *args, **kwargs): + """Create object of event and emit. + + Args: + Same args as '__init__' expects which may be class specific. + """ + from avalon import pipeline + + obj = cls(*args, **kwargs) + pipeline.emit(obj.topic, [obj]) + return obj + + +class BeforeWorkfileSave(BaseEvent): + """Before workfile changes event data.""" + def __init__(self, filename, workdir): + super(BeforeWorkfileSave, self).__init__("before.workfile.save") + self.filename = filename + self.workdir_path = workdir diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index 0fa712a301..d3e4ec8a02 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -31,7 +31,7 @@ class DiscoverResult: def publish_plugins_discover(paths=None): """Find and return available pyblish plug-ins - Overriden function from `pyblish` module to be able collect crashed files + Overridden function from `pyblish` module to be able collect crashed files and reason of their crash. Arguments: diff --git a/openpype/plugins/publish/collect_otio_review.py b/openpype/plugins/publish/collect_otio_review.py index 35c77a24cb..4d8147e70d 100644 --- a/openpype/plugins/publish/collect_otio_review.py +++ b/openpype/plugins/publish/collect_otio_review.py @@ -46,7 +46,7 @@ class CollectOtioReview(pyblish.api.InstancePlugin): # loop all tracks and match with name in `reviewTrack` for track in otio_timeline.tracks: - if review_track_name not in track.name: + if review_track_name != track.name: continue # process correct track diff --git a/openpype/plugins/publish/collect_scene_version.py b/openpype/plugins/publish/collect_scene_version.py index 8ed6e25e66..917647c61a 100644 --- a/openpype/plugins/publish/collect_scene_version.py +++ b/openpype/plugins/publish/collect_scene_version.py @@ -11,6 +11,7 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder label = 'Collect Scene Version' + # configurable in Settings hosts = [ "aftereffects", "blender", @@ -26,7 +27,19 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): "tvpaint" ] + # in some cases of headless publishing (for example webpublisher using PS) + # you want to ignore version from name and let integrate use next version + skip_hosts_headless_publish = [] + def process(self, context): + # tests should be close to regular publish as possible + if ( + os.environ.get("HEADLESS_PUBLISH") + and not os.environ.get("IS_TEST") + and context.data["hostName"] in self.skip_hosts_headless_publish): + self.log.debug("Skipping for headless publishing") + return + assert context.data.get('currentFile'), "Cannot get current file" filename = os.path.basename(context.data.get('currentFile')) diff --git a/openpype/plugins/publish/extract_jpeg_exr.py b/openpype/plugins/publish/extract_jpeg_exr.py index 3cb4f8f9cb..d80b7bb9c3 100644 --- a/openpype/plugins/publish/extract_jpeg_exr.py +++ b/openpype/plugins/publish/extract_jpeg_exr.py @@ -24,7 +24,7 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): "imagesequence", "render", "render2d", "source", "plate", "take" ] - hosts = ["shell", "fusion", "resolve", "webpublisher"] + hosts = ["shell", "fusion", "resolve"] enabled = False # presetable attribute diff --git a/openpype/plugins/publish/extract_otio_review.py b/openpype/plugins/publish/extract_otio_review.py index 78570488b3..35adc97442 100644 --- a/openpype/plugins/publish/extract_otio_review.py +++ b/openpype/plugins/publish/extract_otio_review.py @@ -273,6 +273,8 @@ class ExtractOTIOReview(openpype.api.Extractor): src_start = int(avl_start + start) avl_durtation = int(avl_range.duration.value) + self.need_offset = bool(avl_start != 0 and src_start != 0) + # if media start is les then clip requires if src_start < avl_start: # calculate gap @@ -408,11 +410,17 @@ class ExtractOTIOReview(openpype.api.Extractor): """ padding = "{{:0{}d}}".format(self.padding) + + # create frame offset + offset = 0 + if self.need_offset: + offset = 1 + if end_offset: new_frames = list() start_frame = self.used_frames[-1] - for index in range((end_offset + 1), - (int(end_offset + duration) + 1)): + for index in range((end_offset + offset), + (int(end_offset + duration) + offset)): seq_number = padding.format(start_frame + index) self.log.debug( "index: `{}` | seq_number: `{}`".format(index, seq_number)) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index cec2e470b3..bf214d9139 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -389,6 +389,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): repre["ext"] = ext template_data["ext"] = ext + self.log.info(template_name) template = os.path.normpath( anatomy.templates[template_name]["path"]) diff --git a/openpype/plugins/publish/validate_version.py b/openpype/plugins/publish/validate_version.py index e48ce6e3c3..b94152ef2d 100644 --- a/openpype/plugins/publish/validate_version.py +++ b/openpype/plugins/publish/validate_version.py @@ -10,7 +10,7 @@ class ValidateVersion(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder label = "Validate Version" - hosts = ["nuke", "maya", "blender", "standalonepublisher"] + hosts = ["nuke", "maya", "houdini", "blender", "standalonepublisher"] optional = False active = True diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index 639657d68f..abf69645b7 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -252,7 +252,7 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): - required IF start frame is not set when using frames or timecode burnins On initializing class can be set General options through "options_init" arg. - General can be overriden when adding burnin + General can be overridden when adding burnin ''' TOP_CENTERED = ffmpeg_burnins.TOP_CENTERED @@ -549,7 +549,7 @@ def burnins_from_data( codec_data (list): All codec related arguments in list. options (dict): Options for burnins. burnin_values (dict): Contain positioned values. - overwrite (bool): Output will be overriden if already exists, + overwrite (bool): Output will be overwritten if already exists, True by default. Presets must be set separately. Should be dict with 2 keys: diff --git a/openpype/settings/constants.py b/openpype/settings/constants.py index 2ea19ead4b..8cc991c999 100644 --- a/openpype/settings/constants.py +++ b/openpype/settings/constants.py @@ -2,14 +2,14 @@ import re # Metadata keys for work with studio and project overrides -M_OVERRIDEN_KEY = "__overriden_keys__" +M_OVERRIDDEN_KEY = "__overriden_keys__" # Metadata key for storing information about environments M_ENVIRONMENT_KEY = "__environment_keys__" # Metadata key for storing dynamic created labels M_DYNAMIC_KEY_LABEL = "__dynamic_keys_labels__" METADATA_KEYS = ( - M_OVERRIDEN_KEY, + M_OVERRIDDEN_KEY, M_ENVIRONMENT_KEY, M_DYNAMIC_KEY_LABEL ) @@ -32,7 +32,7 @@ KEY_REGEX = re.compile(r"^[{}]+$".format(KEY_ALLOWED_SYMBOLS)) __all__ = ( - "M_OVERRIDEN_KEY", + "M_OVERRIDDEN_KEY", "M_ENVIRONMENT_KEY", "M_DYNAMIC_KEY_LABEL", diff --git a/openpype/settings/defaults/project_anatomy/templates.json b/openpype/settings/defaults/project_anatomy/templates.json index 9a03b893bf..d46d449c77 100644 --- a/openpype/settings/defaults/project_anatomy/templates.json +++ b/openpype/settings/defaults/project_anatomy/templates.json @@ -27,5 +27,10 @@ "path": "{@folder}/{@file}" }, "delivery": {}, + "unreal": { + "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}/{subset}/{@version}", + "file": "{subset}_{@version}<_{output}><.{@frame}>.{ext}", + "path": "{@folder}/{@file}" + }, "others": {} } \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index cff1259c98..9c0c6f6958 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -3,6 +3,24 @@ "CollectAnatomyInstanceData": { "follow_workfile_version": false }, + "CollectSceneVersion": { + "hosts": [ + "aftereffects", + "blender", + "celaction", + "fusion", + "harmony", + "hiero", + "houdini", + "maya", + "nuke", + "photoshop", + "resolve", + "tvpaint" + ], + "skip_hosts_headless_publish": [ + ] + }, "ValidateEditorialAssetName": { "enabled": true, "optional": false @@ -219,7 +237,7 @@ "hosts": [], "task_types": [], "tasks": [], - "template": "{family}{Variant}" + "template": "{family}{variant}" }, { "families": [ @@ -264,6 +282,17 @@ "task_types": [], "tasks": [], "template": "render{Task}{Variant}" + }, + { + "families": [ + "unrealStaticMesh" + ], + "hosts": [ + "maya" + ], + "task_types": [], + "tasks": [], + "template": "S_{asset}{variant}" } ] }, @@ -297,6 +326,7 @@ "family_filter_profiles": [ { "hosts": [], + "is_include": true, "task_types": [], "filter_families": [] } diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index a756071106..52b8db058c 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -46,6 +46,20 @@ "aov_separator": "underscore", "default_render_image_folder": "renders" }, + "CreateUnrealStaticMesh": { + "enabled": true, + "defaults": [ + "", + "_Main" + ], + "static_mesh_prefix": "S_", + "collision_prefixes": [ + "UBX", + "UCP", + "USP", + "UCX" + ] + }, "CreateAnimation": { "enabled": true, "defaults": [ @@ -123,12 +137,6 @@ "Anim" ] }, - "CreateUnrealStaticMesh": { - "enabled": true, - "defaults": [ - "Main" - ] - }, "CreateVrayProxy": { "enabled": true, "defaults": [ @@ -180,6 +188,18 @@ "whitelist_native_plugins": false, "authorized_plugins": [] }, + "ValidateCycleError": { + "enabled": true, + "optional": false, + "families": [ + "rig" + ] + }, + "ValidateUnrealStaticMeshName": { + "enabled": true, + "validate_mesh": false, + "validate_collision": true + }, "ValidateRenderSettings": { "arnold_render_attributes": [], "vray_render_attributes": [], @@ -197,6 +217,11 @@ "regex": "(.*)_(\\d)*_(?P.*)_(GEO)", "top_level_regex": ".*_GRP" }, + "ValidateModelContent": { + "enabled": true, + "optional": false, + "validate_top_group": true + }, "ValidateTransformNamingSuffix": { "enabled": true, "SUFFIX_NAMING_TABLE": { @@ -281,11 +306,6 @@ "optional": true, "active": true }, - "ValidateModelContent": { - "enabled": true, - "optional": false, - "validate_top_group": true - }, "ValidateNoAnimation": { "enabled": false, "optional": true, diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json index db9bf87268..31cd815dd8 100644 --- a/openpype/settings/defaults/project_settings/photoshop.json +++ b/openpype/settings/defaults/project_settings/photoshop.json @@ -12,7 +12,7 @@ { "color_code": [], "layer_name_regex": [], - "family": "", + "family": "image", "subset_template_name": "" } ] diff --git a/openpype/settings/defaults/system_settings/general.json b/openpype/settings/defaults/system_settings/general.json index 7c78de9a5c..5a3e39e5b6 100644 --- a/openpype/settings/defaults/system_settings/general.json +++ b/openpype/settings/defaults/system_settings/general.json @@ -2,9 +2,6 @@ "studio_name": "Studio name", "studio_code": "stu", "admin_password": "", - "production_version": "", - "staging_version": "", - "version_check_interval": 5, "environment": { "__environment_keys__": { "global": [] @@ -19,5 +16,8 @@ "windows": [], "darwin": [], "linux": [] - } + }, + "production_version": "", + "staging_version": "", + "version_check_interval": 5 } \ No newline at end of file diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index 582937481a..b5bc44640b 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -752,7 +752,7 @@ class BaseItemEntity(BaseEntity): @abstractmethod def _add_to_project_override(self, on_change_trigger): - """Item's implementation to set values as overriden for project. + """Item's implementation to set values as overridden for project. Mark item and all it's children to be stored as project overrides. """ @@ -794,7 +794,7 @@ class BaseItemEntity(BaseEntity): """Item's implementation to remove project overrides. Mark item as does not have project overrides. Must not change - `was_overriden` attribute value. + `was_overridden` attribute value. Args: on_change_trigger (list): Callbacks of `on_change` should be stored diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index 92512a6668..963fd406ed 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -6,7 +6,7 @@ from .lib import ( ) from openpype.settings.constants import ( METADATA_KEYS, - M_OVERRIDEN_KEY, + M_OVERRIDDEN_KEY, KEY_REGEX ) from . import ( @@ -119,7 +119,7 @@ class DictConditionalEntity(ItemEntity): # `current_metadata` are still when schema is loaded # - only metadata stored with dict item are gorup overrides in - # M_OVERRIDEN_KEY + # M_OVERRIDDEN_KEY self._current_metadata = {} self._metadata_are_modified = False @@ -377,9 +377,9 @@ class DictConditionalEntity(ItemEntity): ): continue - if M_OVERRIDEN_KEY not in current_metadata: - current_metadata[M_OVERRIDEN_KEY] = [] - current_metadata[M_OVERRIDEN_KEY].append(key) + if M_OVERRIDDEN_KEY not in current_metadata: + current_metadata[M_OVERRIDDEN_KEY] = [] + current_metadata[M_OVERRIDDEN_KEY].append(key) # Define if current metadata are avaialble for current override state metadata = NOT_SET @@ -535,7 +535,7 @@ class DictConditionalEntity(ItemEntity): enum_value = value.get(self.enum_key) - old_metadata = metadata.get(M_OVERRIDEN_KEY) + old_metadata = metadata.get(M_OVERRIDDEN_KEY) if old_metadata: old_metadata_set = set(old_metadata) new_metadata = [] @@ -547,7 +547,7 @@ class DictConditionalEntity(ItemEntity): for key in old_metadata_set: new_metadata.append(key) - metadata[M_OVERRIDEN_KEY] = new_metadata + metadata[M_OVERRIDDEN_KEY] = new_metadata return value, metadata diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py index c477a0eb0f..060f8d522e 100644 --- a/openpype/settings/entities/dict_immutable_keys_entity.py +++ b/openpype/settings/entities/dict_immutable_keys_entity.py @@ -9,7 +9,7 @@ from .lib import ( ) from openpype.settings.constants import ( METADATA_KEYS, - M_OVERRIDEN_KEY, + M_OVERRIDDEN_KEY, KEY_REGEX ) from . import ( @@ -183,7 +183,7 @@ class DictImmutableKeysEntity(ItemEntity): # `current_metadata` are still when schema is loaded # - only metadata stored with dict item are gorup overrides in - # M_OVERRIDEN_KEY + # M_OVERRIDDEN_KEY self._current_metadata = {} self._metadata_are_modified = False @@ -257,9 +257,9 @@ class DictImmutableKeysEntity(ItemEntity): ): continue - if M_OVERRIDEN_KEY not in current_metadata: - current_metadata[M_OVERRIDEN_KEY] = [] - current_metadata[M_OVERRIDEN_KEY].append(key) + if M_OVERRIDDEN_KEY not in current_metadata: + current_metadata[M_OVERRIDDEN_KEY] = [] + current_metadata[M_OVERRIDDEN_KEY].append(key) # Define if current metadata are avaialble for current override state metadata = NOT_SET @@ -399,7 +399,7 @@ class DictImmutableKeysEntity(ItemEntity): if key in value: metadata[key] = value.pop(key) - old_metadata = metadata.get(M_OVERRIDEN_KEY) + old_metadata = metadata.get(M_OVERRIDDEN_KEY) if old_metadata: old_metadata_set = set(old_metadata) new_metadata = [] @@ -410,7 +410,7 @@ class DictImmutableKeysEntity(ItemEntity): for key in old_metadata_set: new_metadata.append(key) - metadata[M_OVERRIDEN_KEY] = new_metadata + metadata[M_OVERRIDDEN_KEY] = new_metadata return value, metadata diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py index 08b0f75649..6b9c0bc7ed 100644 --- a/openpype/settings/entities/dict_mutable_keys_entity.py +++ b/openpype/settings/entities/dict_mutable_keys_entity.py @@ -222,7 +222,7 @@ class DictMutableKeysEntity(EndpointEntity): self.required_keys = self.schema_data.get("required_keys") or [] self.collapsible_key = self.schema_data.get("collapsible_key") or False # GUI attributes - self.hightlight_content = ( + self.highlight_content = ( self.schema_data.get("highlight_content") or False ) diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index fb6099e82a..0fcd8f3002 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -121,6 +121,20 @@ class EnumEntity(BaseEnumEntity): ) super(EnumEntity, self).schema_validations() + def set_override_state(self, *args, **kwargs): + super(EnumEntity, self).set_override_state(*args, **kwargs) + + # Make sure current value is valid + if self.multiselection: + new_value = [] + for key in self._current_value: + if key in self.valid_keys: + new_value.append(key) + self._current_value = new_value + + elif self._current_value not in self.valid_keys: + self._current_value = self.value_on_not_set + class HostsEnumEntity(BaseEnumEntity): """Enumeration of host names. diff --git a/openpype/settings/entities/lib.py b/openpype/settings/entities/lib.py index bf3868c08d..1c7dc9bed0 100644 --- a/openpype/settings/entities/lib.py +++ b/openpype/settings/entities/lib.py @@ -101,7 +101,7 @@ class OverrideState: - DEFAULTS - Entity cares only about default values. It is not possible to set higher state if any entity does not have filled default value. - - STUDIO - First layer of overrides. Hold only studio overriden values + - STUDIO - First layer of overrides. Hold only studio overridden values that are applied on top of defaults. - PROJECT - Second layer of overrides. Hold only project overrides that are applied on top of defaults and studio overrides. diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index 4e8dcc36ce..dd7601c017 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -10,7 +10,7 @@ - `"is_file"` - this key is for storing openpype defaults in `openpype` repo - reasons of existence: developing new schemas does not require to create defaults manually - key is validated, must be once in hierarchy else it won't be possible to store openpype defaults - - `"is_group"` - define that all values under key in hierarchy will be overriden if any value is modified, this information is also stored to overrides + - `"is_group"` - define that all values under key in hierarchy will be overridden if any value is modified, this information is also stored to overrides - this keys is not allowed for all inputs as they may have not reason for that - key is validated, can be only once in hierarchy but is not required - currently there are `system settings` and `project settings` @@ -767,7 +767,7 @@ Anatomy represents data stored on project document. ### anatomy - entity works similarly to `dict` -- anatomy has always all keys overriden with overrides +- anatomy has always all keys overridden with overrides - overrides are not applied as all anatomy data must be available from project document - all children must be groups diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_templates.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_templates.json index e208069e6f..0548824ee1 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_templates.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_templates.json @@ -143,6 +143,28 @@ "label": "Delivery", "object_type": "text" }, + { + "type": "dict", + "key": "unreal", + "label": "Unreal", + "children": [ + { + "type": "text", + "key": "folder", + "label": "Folder" + }, + { + "type": "text", + "key": "file", + "label": "File" + }, + { + "type": "text", + "key": "path", + "label": "Path" + } + ] + }, { "type": "dict-modifiable", "key": "others", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index d146f3cf15..3f9776bcd6 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json @@ -18,6 +18,27 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CollectSceneVersion", + "label": "Collect Version from Workfile", + "is_group": true, + "children": [ + { + "key": "hosts", + "label": "Host names", + "type": "hosts-enum", + "multiselection": true + }, + { + "key": "skip_hosts_headless_publish", + "label": "Skip for host if headless publish", + "type": "hosts-enum", + "multiselection": true + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json index bb71c9bde6..f8c9482e5f 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json @@ -267,7 +267,9 @@ "label": "Task types" }, { - "type": "splitter" + "type": "boolean", + "key": "is_include", + "label": "Exclude / Include" }, { "type": "template", 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 088d5d1f96..0544b4bab7 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 @@ -66,6 +66,38 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CreateUnrealStaticMesh", + "label": "Create Unreal - Static Mesh", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "defaults", + "label": "Default Subsets", + "object_type": "text" + }, + { + "type": "text", + "key": "static_mesh_prefix", + "label": "Static Mesh Prefix" + }, + { + "type": "list", + "key": "collision_prefixes", + "label": "Collision Mesh Prefixes", + "object_type": "text" + } + ] + + }, { "type": "schema_template", "name": "template_create_plugin", @@ -118,10 +150,6 @@ "key": "CreateSetDress", "label": "Create Set Dress" }, - { - "key": "CreateUnrealStaticMesh", - "label": "Create Unreal - Static Mesh" - }, { "key": "CreateVrayProxy", "label": "Create VRay Proxy" diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 7c9a5a6b46..5a47d688b5 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -129,6 +129,58 @@ ] }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateUnrealStaticMeshName", + "label": "Validate Unreal Static Mesh Name", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "validate_mesh", + "label": "Validate mesh Names " + }, + { + "type": "boolean", + "key": "validate_collision", + "label": "Validate collision names" + } + ] + }, + + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "ValidateCycleError", + "label": "Validate Cycle Error", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + } + ] + }, + { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_publish_families.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_publish_families.json index 9db1427562..f39ad31fbb 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_publish_families.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_publish_families.json @@ -10,23 +10,39 @@ "multiselection": "{multiselection}", "type": "enum", "enum_items": [ - {"action": "action"}, - {"animation": "animation"}, - {"audio": "audio"}, - {"camera": "camera"}, - {"editorial": "editorial"}, - {"layout": "layout"}, - {"look": "look"}, - {"mayaAscii": "mayaAscii"}, - {"model": "model"}, - {"pointcache": "pointcache"}, - {"reference": "reference"}, - {"render": "render"}, - {"review": "review"}, - {"rig": "rig"}, - {"setdress": "setdress"}, - {"workfile": "workfile"}, - {"xgen": "xgen"} + {"action": "action"}, + {"animation": "animation"}, + {"assembly": "assembly"}, + {"audio": "audio"}, + {"backgroundComp": "backgroundComp"}, + {"backgroundLayout": "backgroundLayout"}, + {"camera": "camera"}, + {"editorial": "editorial"}, + {"gizmo": "gizmo"}, + {"image": "image"}, + {"layout": "layout"}, + {"look": "look"}, + {"matchmove": "matchmove"}, + {"mayaScene": "mayaScene"}, + {"model": "model"}, + {"nukenodes": "nukenodes"}, + {"plate": "plate"}, + {"pointcache": "pointcache"}, + {"prerender": "prerender"}, + {"redshiftproxy": "redshiftproxy"}, + {"reference": "reference"}, + {"render": "render"}, + {"review": "review"}, + {"rig": "rig"}, + {"setdress": "setdress"}, + {"take": "take"}, + {"usdShade": "usdShade"}, + {"vdbcache": "vdbcache"}, + {"vrayproxy": "vrayproxy"}, + {"workfile": "workfile"}, + {"xgen": "xgen"}, + {"yetiRig": "yetiRig"}, + {"yeticache": "yeticache"} ] } ] diff --git a/openpype/settings/entities/schemas/system_schema/example_schema.json b/openpype/settings/entities/schemas/system_schema/example_schema.json index c30e1f6848..6a86dae259 100644 --- a/openpype/settings/entities/schemas/system_schema/example_schema.json +++ b/openpype/settings/entities/schemas/system_schema/example_schema.json @@ -11,13 +11,13 @@ }, { "type": "dict-conditional", - "key": "overriden_value", - "label": "Overriden value", - "enum_key": "overriden", + "key": "overridden_value", + "label": "Overridden value", + "enum_key": "overridden", "enum_is_horizontal": true, "enum_children": [ { - "key": "overriden", + "key": "overridden", "label": "Override value", "children": [ { diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index 3af3f5ce35..6306317df8 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -30,36 +30,6 @@ { "type": "splitter" }, - { - "type": "label", - "label": "Define explicit OpenPype version that should be used. Keep empty to use latest available version." - }, - { - "type": "production-versions-text", - "key": "production_version", - "label": "Production version" - }, - { - "type": "staging-versions-text", - "key": "staging_version", - "label": "Staging version" - }, - { - "type": "splitter" - }, - { - "type": "label", - "label": "Trigger validation if running OpenPype is using studio defined version each 'n' minutes. Validation happens in OpenPype tray application." - }, - { - "type": "number", - "key": "version_check_interval", - "label": "Version check interval", - "minimum": 0 - }, - { - "type": "splitter" - }, { "key": "environment", "label": "Environment", @@ -141,12 +111,49 @@ "type": "splitter" }, { - "type": "path", - "key": "openpype_path", - "label": "Versions Repository", - "multiplatform": true, - "multipath": true, - "require_restart": true + "type": "collapsible-wrap", + "label": "OpenPype deployment control", + "collapsible": false, + "children": [ + { + "type": "path", + "key": "openpype_path", + "label": "Versions Repository", + "multiplatform": true, + "multipath": true, + "require_restart": true + }, + { + "type": "splitter" + }, + { + "type": "label", + "label": "Define explicit OpenPype version that should be used. Keep empty to use latest available version." + }, + { + "type": "production-versions-text", + "key": "production_version", + "label": "Production version" + }, + { + "type": "staging-versions-text", + "key": "staging_version", + "label": "Staging version" + }, + { + "type": "splitter" + }, + { + "type": "label", + "label": "Trigger validation if running OpenPype is using studio defined version each 'n' minutes. Validation happens in OpenPype tray application." + }, + { + "type": "number", + "key": "version_check_interval", + "label": "Version check interval", + "minimum": 0 + } + ] } ] } diff --git a/openpype/settings/handlers.py b/openpype/settings/handlers.py index 51e390bb6d..af05bbf413 100644 --- a/openpype/settings/handlers.py +++ b/openpype/settings/handlers.py @@ -13,7 +13,7 @@ from .constants import ( PROJECT_SETTINGS_KEY, PROJECT_ANATOMY_KEY, LOCAL_SETTING_KEY, - M_OVERRIDEN_KEY + M_OVERRIDDEN_KEY ) from .lib import load_json_file @@ -254,12 +254,12 @@ class MongoSettingsHandler(SettingsHandler): continue # Pop key from values output[key] = general_data.pop(key) - # Pop key from overriden metadata + # Pop key from overridden metadata if ( - M_OVERRIDEN_KEY in general_data - and key in general_data[M_OVERRIDEN_KEY] + M_OVERRIDDEN_KEY in general_data + and key in general_data[M_OVERRIDDEN_KEY] ): - general_data[M_OVERRIDEN_KEY].remove(key) + general_data[M_OVERRIDDEN_KEY].remove(key) return output def _apply_global_settings( @@ -319,17 +319,17 @@ class MongoSettingsHandler(SettingsHandler): system_general = {} system_settings_data["general"] = system_general - overriden_keys = system_general.get(M_OVERRIDEN_KEY) or [] + overridden_keys = system_general.get(M_OVERRIDDEN_KEY) or [] for key in self.global_general_keys: if key not in globals_data: continue system_general[key] = globals_data[key] - if key not in overriden_keys: - overriden_keys.append(key) + if key not in overridden_keys: + overridden_keys.append(key) - if overriden_keys: - system_general[M_OVERRIDEN_KEY] = overriden_keys + if overridden_keys: + system_general[M_OVERRIDDEN_KEY] = overridden_keys return system_settings_document diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index 43489aecfd..1b5682536a 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -8,7 +8,7 @@ from .exceptions import ( SaveWarningExc ) from .constants import ( - M_OVERRIDEN_KEY, + M_OVERRIDDEN_KEY, M_ENVIRONMENT_KEY, METADATA_KEYS, @@ -546,13 +546,13 @@ def subkey_merge(_dict, value, keys): def merge_overrides(source_dict, override_dict): """Merge data from override_dict to source_dict.""" - if M_OVERRIDEN_KEY in override_dict: - overriden_keys = set(override_dict.pop(M_OVERRIDEN_KEY)) + if M_OVERRIDDEN_KEY in override_dict: + overridden_keys = set(override_dict.pop(M_OVERRIDDEN_KEY)) else: - overriden_keys = set() + overridden_keys = set() for key, value in override_dict.items(): - if (key in overriden_keys or key not in source_dict): + if (key in overridden_keys or key not in source_dict): source_dict[key] = value elif isinstance(value, dict) and isinstance(source_dict[key], dict): @@ -574,7 +574,7 @@ def apply_local_settings_on_system_settings(system_settings, local_settings): """Apply local settings on studio system settings. ATM local settings can modify only application executables. Executable - values are not overriden but prepended. + values are not overridden but prepended. """ if not local_settings or "applications" not in local_settings: return @@ -914,7 +914,7 @@ def get_environments(): """Calculated environment based on defaults and system settings. Any default environment also found in the system settings will be fully - overriden by the one from the system settings. + overridden by the one from the system settings. Returns: dict: Output should be ready for `acre` module. diff --git a/openpype/style/data.json b/openpype/style/data.json index 1db0c732cf..e65690378d 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -112,7 +112,7 @@ "breadcrumbs-btn-bg": "rgba(127, 127, 127, 60)", "breadcrumbs-btn-bg-hover": "rgba(127, 127, 127, 90)", - "content-hightlighted": "rgba(19, 26, 32, 15)", + "content-highlighted": "rgba(19, 26, 32, 15)", "focus-border": "#839caf", "image-btn": "#bfccd6", "image-btn-hover": "#189aea", diff --git a/openpype/style/style.css b/openpype/style/style.css index d9b0ff7421..03b7b522f9 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -1093,16 +1093,16 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { #ExpandLabel[state="modified"]:hover, #SettingsLabel[state="modified"]:hover { color: {color:settings:modified-light}; } -#ExpandLabel[state="overriden-modified"], #SettingsLabel[state="overriden-modified"] { +#ExpandLabel[state="overridden-modified"], #SettingsLabel[state="overridden-modified"] { color: {color:settings:modified-mid}; } -#ExpandLabel[state="overriden-modified"]:hover, #SettingsLabel[state="overriden-modified"]:hover { +#ExpandLabel[state="overridden-modified"]:hover, #SettingsLabel[state="overridden-modified"]:hover { color: {color:settings:modified-light}; } -#ExpandLabel[state="overriden"], #SettingsLabel[state="overriden"] { +#ExpandLabel[state="overridden"], #SettingsLabel[state="overridden"] { color: {color:settings:project-mid}; } -#ExpandLabel[state="overriden"]:hover, #SettingsLabel[state="overriden"]:hover { +#ExpandLabel[state="overridden"]:hover, #SettingsLabel[state="overridden"]:hover { color: {color:settings:project-light}; } #ExpandLabel[state="invalid"], #SettingsLabel[state="invalid"] { @@ -1116,10 +1116,10 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { #SettingsMainWidget QWidget[input-state="modified"] { border-color: {color:settings:modified-mid}; } -#SettingsMainWidget QWidget[input-state="overriden-modified"] { +#SettingsMainWidget QWidget[input-state="overridden-modified"] { border-color: {color:settings:modified-mid}; } -#SettingsMainWidget QWidget[input-state="overriden"] { +#SettingsMainWidget QWidget[input-state="overridden"] { border-color: {color:settings:project-mid}; } #SettingsMainWidget QWidget[input-state="invalid"] { @@ -1145,8 +1145,8 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { #ContentWidget { background-color: transparent; } -#ContentWidget[content_state="hightlighted"] { - background-color: {color:settings:content-hightlighted}; +#ContentWidget[content_state="highlighted"] { + background-color: {color:settings:content-highlighted}; } #SideLineWidget { @@ -1172,11 +1172,11 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { #SideLineWidget[state="child-invalid"] {border-color: {color:settings:invalid-dark};} #SideLineWidget[state="child-invalid"]:hover {border-color: {color:settings:invalid-light};} -#SideLineWidget[state="child-overriden"] {border-color: {color:settings:project-dark};} -#SideLineWidget[state="child-overriden"]:hover {border-color: {color:settings:project-mid};} +#SideLineWidget[state="child-overridden"] {border-color: {color:settings:project-dark};} +#SideLineWidget[state="child-overridden"]:hover {border-color: {color:settings:project-mid};} -#SideLineWidget[state="child-overriden-modified"] {border-color: {color:settings:modified-dark};} -#SideLineWidget[state="child-overriden-modified"]:hover {border-color: {color:settings:modified-mid};} +#SideLineWidget[state="child-overridden-modified"] {border-color: {color:settings:modified-dark};} +#SideLineWidget[state="child-overridden-modified"]:hover {border-color: {color:settings:modified-mid};} #DictAsWidgetBody { background: transparent; diff --git a/openpype/tests/test_avalon_plugin_presets.py b/openpype/tests/test_avalon_plugin_presets.py index cc1858554c..ec21385d23 100644 --- a/openpype/tests/test_avalon_plugin_presets.py +++ b/openpype/tests/test_avalon_plugin_presets.py @@ -32,9 +32,9 @@ def test_avalon_plugin_presets(monkeypatch, printer): assert MyTestCreator in plugins for p in plugins: if p.__name__ == "MyTestCreator": - printer("Test if we have overriden existing property") + printer("Test if we have overridden existing property") assert p.my_test_property == "B" - printer("Test if we have overriden superclass property") + printer("Test if we have overridden superclass property") assert p.active is False printer("Test if we have added new property") assert p.new_property == "new" diff --git a/openpype/tools/assetcreator/app.py b/openpype/tools/assetcreator/app.py index 58697e8aa3..1d332d647e 100644 --- a/openpype/tools/assetcreator/app.py +++ b/openpype/tools/assetcreator/app.py @@ -87,7 +87,7 @@ class Window(QtWidgets.QDialog): btn_layout = QtWidgets.QHBoxLayout(btns_widget) btn_create_asset = QtWidgets.QPushButton("Create asset") btn_create_asset.setToolTip( - "Creates all neccessary components for asset" + "Creates all necessary components for asset" ) checkbox_app = None if self.context is not None: @@ -231,7 +231,7 @@ class Window(QtWidgets.QDialog): test_name = name.replace(' ', '') error_message = None message = QtWidgets.QMessageBox(self) - message.setWindowTitle("Some errors has occured") + message.setWindowTitle("Some errors have occurred") message.setIcon(QtWidgets.QMessageBox.Critical) # TODO: show error messages on any error if self.valid_parent is not True and test_name == '': diff --git a/openpype/tools/assetcreator/widget.py b/openpype/tools/assetcreator/widget.py index 1e9e4ab624..fd0f438e68 100644 --- a/openpype/tools/assetcreator/widget.py +++ b/openpype/tools/assetcreator/widget.py @@ -44,7 +44,7 @@ def preserve_expanded_rows(tree_view, This function is created to maintain the expand vs collapse status of the model items. When refresh is triggered the items which are expanded - will stay expanded and vise versa. + will stay expanded and vice versa. Arguments: tree_view (QWidgets.QTreeView): the tree view which is @@ -94,7 +94,7 @@ def preserve_selection(tree_view, This function is created to maintain the selection status of the model items. When refresh is triggered the items which are expanded - will stay expanded and vise versa. + will stay expanded and vice versa. tree_view (QWidgets.QTreeView): the tree view nested in the application column (int): the column to retrieve the data from @@ -179,7 +179,7 @@ class AssetModel(TreeModel): """ if silos: # WARNING: Silo item "_id" is set to silo value - # mainly because GUI issue with perserve selection and expanded row + # mainly because GUI issue with preserve selection and expanded row # and because of easier hierarchy parenting (in "assets") for silo in silos: item = Item({ diff --git a/openpype/tools/context_dialog/window.py b/openpype/tools/context_dialog/window.py index 5d8a2ad62e..c8464faa3e 100644 --- a/openpype/tools/context_dialog/window.py +++ b/openpype/tools/context_dialog/window.py @@ -46,7 +46,7 @@ class ContextDialog(QtWidgets.QDialog): # UI initialization main_splitter = QtWidgets.QSplitter(self) - # Left side widget containt project combobox and asset widget + # Left side widget contains project combobox and asset widget left_side_widget = QtWidgets.QWidget(main_splitter) project_combobox = QtWidgets.QComboBox(left_side_widget) diff --git a/openpype/tools/creator/window.py b/openpype/tools/creator/window.py index 22a6d5ce9c..f1d0849dfe 100644 --- a/openpype/tools/creator/window.py +++ b/openpype/tools/creator/window.py @@ -354,7 +354,7 @@ class CreatorWindow(QtWidgets.QDialog): Override keyPressEvent to do nothing so that Maya's panels won't take focus when pressing "SHIFT" whilst mouse is over viewport or - outliner. This way users don't accidently perform Maya commands + outliner. This way users don't accidentally perform Maya commands whilst trying to name an instance. """ diff --git a/openpype/tools/experimental_tools/dialog.py b/openpype/tools/experimental_tools/dialog.py index ad65caa8e3..295afbe68d 100644 --- a/openpype/tools/experimental_tools/dialog.py +++ b/openpype/tools/experimental_tools/dialog.py @@ -107,7 +107,7 @@ class ExperimentalToolsDialog(QtWidgets.QDialog): # Is dialog first shown self._first_show = True - # Trigger refresh when window get's activity + # Trigger refresh when window gets activity self._refresh_on_active = True # Is window active self._window_is_active = False diff --git a/openpype/tools/experimental_tools/tools_def.py b/openpype/tools/experimental_tools/tools_def.py index 991eb5e4a3..316359c0f3 100644 --- a/openpype/tools/experimental_tools/tools_def.py +++ b/openpype/tools/experimental_tools/tools_def.py @@ -43,7 +43,7 @@ class ExperimentalTool: self._enabled = enabled def execute(self): - """Trigger registerd callback.""" + """Trigger registered callback.""" self.callback() diff --git a/openpype/tools/launcher/actions.py b/openpype/tools/launcher/actions.py index 4d86970f9c..fbaef05261 100644 --- a/openpype/tools/launcher/actions.py +++ b/openpype/tools/launcher/actions.py @@ -62,6 +62,7 @@ class ApplicationAction(api.Action): icon = None color = None order = 0 + data = {} _log = None required_session_keys = ( @@ -103,7 +104,8 @@ class ApplicationAction(api.Action): self.application.launch( project_name=project_name, asset_name=asset_name, - task_name=task_name + task_name=task_name, + **self.data ) except ApplictionExecutableNotFound as exc: diff --git a/openpype/tools/launcher/constants.py b/openpype/tools/launcher/constants.py index 7f394cb5ac..61f631759b 100644 --- a/openpype/tools/launcher/constants.py +++ b/openpype/tools/launcher/constants.py @@ -7,6 +7,8 @@ VARIANT_GROUP_ROLE = QtCore.Qt.UserRole + 2 ACTION_ID_ROLE = QtCore.Qt.UserRole + 3 ANIMATION_START_ROLE = QtCore.Qt.UserRole + 4 ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 5 +FORCE_NOT_OPEN_WORKFILE_ROLE = QtCore.Qt.UserRole + 6 +ACTION_TOOLTIP_ROLE = QtCore.Qt.UserRole + 7 # Animation length in seconds ANIMATION_LEN = 7 diff --git a/openpype/tools/launcher/delegates.py b/openpype/tools/launcher/delegates.py index cef0f5e1a2..7b53658727 100644 --- a/openpype/tools/launcher/delegates.py +++ b/openpype/tools/launcher/delegates.py @@ -2,7 +2,8 @@ import time from Qt import QtCore, QtWidgets, QtGui from .constants import ( ANIMATION_START_ROLE, - ANIMATION_STATE_ROLE + ANIMATION_STATE_ROLE, + FORCE_NOT_OPEN_WORKFILE_ROLE ) @@ -69,6 +70,16 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): self._draw_animation(painter, option, index) super(ActionDelegate, self).paint(painter, option, index) + + if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): + rect = QtCore.QRectF(option.rect.x(), option.rect.height(), + 5, 5) + painter.setPen(QtCore.Qt.transparent) + painter.setBrush(QtGui.QColor(200, 0, 0)) + painter.drawEllipse(rect) + + painter.setBrush(self.extender_bg_brush) + is_group = False for group_role in self.group_roles: is_group = index.data(group_role) diff --git a/openpype/tools/launcher/lib.py b/openpype/tools/launcher/lib.py index d6374f49d2..4d678b96ae 100644 --- a/openpype/tools/launcher/lib.py +++ b/openpype/tools/launcher/lib.py @@ -29,7 +29,7 @@ class ProjectHandler(QtCore.QObject): Helps to organize two separate widgets handling current project selection. It is easier to trigger project change callbacks from one place than from - multiple differect places without proper handling or sequence changes. + multiple different places without proper handling or sequence changes. Args: dbcon(AvalonMongoDB): Mongo connection with Session. @@ -42,7 +42,7 @@ class ProjectHandler(QtCore.QObject): # that may require reshing of projects refresh_interval = 10000 - # Signal emmited when project has changed + # Signal emitted when project has changed project_changed = QtCore.Signal(str) projects_refreshed = QtCore.Signal() timer_timeout = QtCore.Signal() diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index 427475cb4b..6ade9d33ed 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -2,19 +2,21 @@ import uuid import copy import logging import collections +import appdirs from . import lib from .constants import ( ACTION_ROLE, GROUP_ROLE, VARIANT_GROUP_ROLE, - ACTION_ID_ROLE + ACTION_ID_ROLE, + FORCE_NOT_OPEN_WORKFILE_ROLE ) from .actions import ApplicationAction from Qt import QtCore, QtGui from avalon.vendor import qtawesome from avalon import style, api -from openpype.lib import ApplicationManager +from openpype.lib import ApplicationManager, JSONSettingRegistry log = logging.getLogger(__name__) @@ -30,6 +32,13 @@ class ActionModel(QtGui.QStandardItemModel): # Cache of available actions self._registered_actions = list() self.items_by_id = {} + path = appdirs.user_data_dir("openpype", "pypeclub") + self.launcher_registry = JSONSettingRegistry("launcher", path) + + try: + _ = self.launcher_registry.get_item("force_not_open_workfile") + except ValueError: + self.launcher_registry.set_item("force_not_open_workfile", []) def discover(self): """Set up Actions cache. Run this for each new project.""" @@ -75,7 +84,8 @@ class ActionModel(QtGui.QStandardItemModel): "group": None, "icon": app.icon, "color": getattr(app, "color", None), - "order": getattr(app, "order", None) or 0 + "order": getattr(app, "order", None) or 0, + "data": {} } ) @@ -102,7 +112,7 @@ class ActionModel(QtGui.QStandardItemModel): # Groups group_name = getattr(action, "group", None) - # Lable variants + # Label variants label = getattr(action, "label", None) label_variant = getattr(action, "label_variant", None) if label_variant and not label: @@ -179,11 +189,17 @@ class ActionModel(QtGui.QStandardItemModel): self.beginResetModel() + stored = self.launcher_registry.get_item("force_not_open_workfile") items = [] for order in sorted(items_by_order.keys()): for item in items_by_order[order]: item_id = str(uuid.uuid4()) item.setData(item_id, ACTION_ID_ROLE) + + if self.is_force_not_open_workfile(item, + stored): + self.change_action_item(item, True) + self.items_by_id[item_id] = item items.append(item) @@ -222,6 +238,90 @@ class ActionModel(QtGui.QStandardItemModel): key=lambda action: (action.order, action.name) ) + def update_force_not_open_workfile_settings(self, is_checked, action_id): + """Store/remove config for forcing to skip opening last workfile. + + Args: + is_checked (bool): True to add, False to remove + action_id (str) + """ + action_item = self.items_by_id.get(action_id) + if not action_item: + return + + action = action_item.data(ACTION_ROLE) + actual_data = self._prepare_compare_data(action) + + stored = self.launcher_registry.get_item("force_not_open_workfile") + if is_checked: + stored.append(actual_data) + else: + final_values = [] + for config in stored: + if config != actual_data: + final_values.append(config) + stored = final_values + + self.launcher_registry.set_item("force_not_open_workfile", stored) + self.launcher_registry._get_item.cache_clear() + self.change_action_item(action_item, is_checked) + + def change_action_item(self, item, checked): + """Modifies tooltip and sets if opening of last workfile forbidden""" + tooltip = item.data(QtCore.Qt.ToolTipRole) + if checked: + tooltip += " (Not opening last workfile)" + + item.setData(tooltip, QtCore.Qt.ToolTipRole) + item.setData(checked, FORCE_NOT_OPEN_WORKFILE_ROLE) + + def is_application_action(self, action): + """Checks if item is of a ApplicationAction type + + Args: + action (action) + """ + if isinstance(action, list) and action: + action = action[0] + + return ApplicationAction in action.__bases__ + + def is_force_not_open_workfile(self, item, stored): + """Checks if application for task is marked to not open workfile + + There might be specific tasks where is unwanted to open workfile right + always (broken file, low performance). This allows artist to mark to + skip opening for combination (project, asset, task_name, app) + + Args: + item (QStandardItem) + stored (list) of dict + """ + action = item.data(ACTION_ROLE) + if not self.is_application_action(action): + return False + + actual_data = self._prepare_compare_data(action) + for config in stored: + if config == actual_data: + return True + + return False + + def _prepare_compare_data(self, action): + if isinstance(action, list) and action: + action = action[0] + + compare_data = {} + if action: + compare_data = { + "app_label": action.label.lower(), + "project_name": self.dbcon.Session["AVALON_PROJECT"], + "asset": self.dbcon.Session["AVALON_ASSET"], + "task_name": self.dbcon.Session["AVALON_TASK"] + } + return compare_data + class ProjectModel(QtGui.QStandardItemModel): """List of projects""" diff --git a/openpype/tools/launcher/widgets.py b/openpype/tools/launcher/widgets.py index edda8d08b5..ba0d9dd6b5 100644 --- a/openpype/tools/launcher/widgets.py +++ b/openpype/tools/launcher/widgets.py @@ -15,7 +15,8 @@ from .constants import ( ACTION_ID_ROLE, ANIMATION_START_ROLE, ANIMATION_STATE_ROLE, - ANIMATION_LEN + ANIMATION_LEN, + FORCE_NOT_OPEN_WORKFILE_ROLE ) @@ -96,6 +97,7 @@ class ActionBar(QtWidgets.QWidget): view.setViewMode(QtWidgets.QListView.IconMode) view.setResizeMode(QtWidgets.QListView.Adjust) view.setSelectionMode(QtWidgets.QListView.NoSelection) + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) view.setEditTriggers(QtWidgets.QListView.NoEditTriggers) view.setWrapping(True) view.setGridSize(QtCore.QSize(70, 75)) @@ -135,8 +137,16 @@ class ActionBar(QtWidgets.QWidget): project_handler.projects_refreshed.connect(self._on_projects_refresh) view.clicked.connect(self.on_clicked) + view.customContextMenuRequested.connect(self.on_context_menu) + + self._context_menu = None + self._discover_on_menu = False def discover_actions(self): + if self._context_menu is not None: + self._discover_on_menu = True + return + if self._animation_timer.isActive(): self._animation_timer.stop() self.model.discover() @@ -171,7 +181,7 @@ class ActionBar(QtWidgets.QWidget): self.update() def _start_animation(self, index): - # Offset refresh timout + # Offset refresh timeout self.project_handler.start_timer() action_id = index.data(ACTION_ID_ROLE) item = self.model.items_by_id.get(action_id) @@ -181,6 +191,46 @@ class ActionBar(QtWidgets.QWidget): self._animated_items.add(action_id) self._animation_timer.start() + def on_context_menu(self, point): + """Creates menu to force skip opening last workfile.""" + index = self.view.indexAt(point) + if not index.isValid(): + return + + action_item = index.data(ACTION_ROLE) + if not self.model.is_application_action(action_item): + return + + menu = QtWidgets.QMenu(self.view) + checkbox = QtWidgets.QCheckBox("Skip opening last workfile.", + menu) + if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): + checkbox.setChecked(True) + + action_id = index.data(ACTION_ID_ROLE) + checkbox.stateChanged.connect( + lambda: self.on_checkbox_changed(checkbox.isChecked(), + action_id)) + action = QtWidgets.QWidgetAction(menu) + action.setDefaultWidget(checkbox) + + menu.addAction(action) + + self._context_menu = menu + global_point = self.mapToGlobal(point) + menu.exec_(global_point) + self._context_menu = None + if self._discover_on_menu: + self._discover_on_menu = False + self.discover_actions() + + def on_checkbox_changed(self, is_checked, action_id): + self.model.update_force_not_open_workfile_settings(is_checked, + action_id) + self.view.update() + if self._context_menu is not None: + self._context_menu.close() + def on_clicked(self, index): if not index or not index.isValid(): return @@ -189,11 +239,15 @@ class ActionBar(QtWidgets.QWidget): is_variant_group = index.data(VARIANT_GROUP_ROLE) if not is_group and not is_variant_group: action = index.data(ACTION_ROLE) + if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): + action.data["start_last_workfile"] = False + else: + action.data.pop("start_last_workfile", None) self._start_animation(index) self.action_clicked.emit(action) return - # Offset refresh timout + # Offset refresh timeout self.project_handler.start_timer() actions = index.data(ACTION_ROLE) @@ -212,7 +266,7 @@ class ActionBar(QtWidgets.QWidget): by_variant_label = collections.defaultdict(list) orders = [] for action in actions: - # Lable variants + # Label variants label = getattr(action, "label", None) label_variant = getattr(action, "label_variant", None) if label_variant and not label: diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py index 62bf5538de..1d5dee21f3 100644 --- a/openpype/tools/loader/app.py +++ b/openpype/tools/loader/app.py @@ -27,7 +27,7 @@ module.window = None # Register callback on task change # - callback can't be defined in Window as it is weak reference callback -# so `WeakSet` will remove it immidiatelly +# so `WeakSet` will remove it immediately def on_context_task_change(*args, **kwargs): if module.window: module.window.on_context_task_change(*args, **kwargs) @@ -455,7 +455,7 @@ class LoaderWindow(QtWidgets.QDialog): shift_pressed = QtCore.Qt.ShiftModifier & modifiers if shift_pressed: - print("Force quitted..") + print("Force quit..") self.setAttribute(QtCore.Qt.WA_DeleteOnClose) print("Good bye") diff --git a/openpype/tools/loader/lib.py b/openpype/tools/loader/lib.py index 14ebab6c85..8f18c01913 100644 --- a/openpype/tools/loader/lib.py +++ b/openpype/tools/loader/lib.py @@ -46,7 +46,7 @@ def get_options(action, loader, parent, repre_contexts): Args: action (OptionalAction) - action in menu - loader (cls of api.Loader) - not initilized yet + loader (cls of api.Loader) - not initialized yet parent (Qt element to parent dialog to) repre_contexts (list) of dict with full info about selected repres Returns: diff --git a/openpype/tools/mayalookassigner/app.py b/openpype/tools/mayalookassigner/app.py index fb99333f87..31bb455f95 100644 --- a/openpype/tools/mayalookassigner/app.py +++ b/openpype/tools/mayalookassigner/app.py @@ -24,7 +24,6 @@ from .commands import ( ) from .vray_proxies import vrayproxy_assign_look - module = sys.modules[__name__] module.window = None @@ -210,7 +209,7 @@ class App(QtWidgets.QWidget): # Assign the first matching look relevant for this asset # (since assigning multiple to the same nodes makes no sense) assign_look = next((subset for subset in item["looks"] - if subset["name"] in looks), None) + if subset["name"] in looks), None) if not assign_look: self.echo("{} No matching selected " "look for {}".format(prefix, asset)) @@ -229,11 +228,14 @@ class App(QtWidgets.QWidget): if cmds.pluginInfo('vrayformaya', query=True, loaded=True): self.echo("Getting vray proxy nodes ...") - vray_proxies = set(cmds.ls(type="VRayProxy")) - nodes = list(set(item["nodes"]).difference(vray_proxies)) + vray_proxies = set(cmds.ls(type="VRayProxy", long=True)) + if vray_proxies: for vp in vray_proxies: - vrayproxy_assign_look(vp, subset_name) + if vp in nodes: + vrayproxy_assign_look(vp, subset_name) + + nodes = list(set(item["nodes"]).difference(vray_proxies)) # Assign look if nodes: diff --git a/openpype/tools/mayalookassigner/commands.py b/openpype/tools/mayalookassigner/commands.py index f7d26f9adb..b9402d8ea1 100644 --- a/openpype/tools/mayalookassigner/commands.py +++ b/openpype/tools/mayalookassigner/commands.py @@ -8,7 +8,6 @@ from openpype.hosts.maya.api import lib from avalon import io, api - from .vray_proxies import get_alembic_ids_cache log = logging.getLogger(__name__) @@ -90,6 +89,7 @@ def get_all_asset_nodes(): container_name = container["objectName"] nodes += cmds.sets(container_name, query=True, nodesOnly=True) or [] + nodes = list(set(nodes)) return nodes @@ -106,9 +106,19 @@ def create_asset_id_hash(nodes): # iterate over content of reference node if cmds.nodeType(node) == "reference": ref_hashes = create_asset_id_hash( - cmds.referenceQuery(node, nodes=True)) + list(set(cmds.referenceQuery(node, nodes=True, dp=True)))) for asset_id, ref_nodes in ref_hashes.items(): node_id_hash[asset_id] += ref_nodes + elif cmds.pluginInfo('vrayformaya', query=True, + loaded=True) and cmds.nodeType( + node) == "VRayProxy": + path = cmds.getAttr("{}.fileName".format(node)) + ids = get_alembic_ids_cache(path) + for k, _ in ids.items(): + pid = k.split(":")[0] + if node not in node_id_hash[pid]: + node_id_hash[pid].append(node) + else: value = lib.get_id(node) if value is None: @@ -141,22 +151,8 @@ def create_items_from_nodes(nodes): id_hashes = create_asset_id_hash(nodes) - # get ids from alembic - if cmds.pluginInfo('vrayformaya', query=True, loaded=True): - vray_proxy_nodes = cmds.ls(nodes, type="VRayProxy") - for vp in vray_proxy_nodes: - path = cmds.getAttr("{}.fileName".format(vp)) - ids = get_alembic_ids_cache(path) - parent_id = {} - for k, _ in ids.items(): - pid = k.split(":")[0] - if not parent_id.get(pid): - parent_id.update({pid: [vp]}) - - print("Adding ids from alembic {}".format(path)) - id_hashes.update(parent_id) - if not id_hashes: + log.warning("No id hashes") return asset_view_items for _id, id_nodes in id_hashes.items(): diff --git a/openpype/tools/mayalookassigner/vray_proxies.py b/openpype/tools/mayalookassigner/vray_proxies.py index d2f345e628..ecc9a2a873 100644 --- a/openpype/tools/mayalookassigner/vray_proxies.py +++ b/openpype/tools/mayalookassigner/vray_proxies.py @@ -41,7 +41,12 @@ def get_alembic_paths_by_property(filename, attr, verbose=False): filename = filename.replace("\\", "/") filename = str(filename) # path must be string - archive = alembic.Abc.IArchive(filename) + try: + archive = alembic.Abc.IArchive(filename) + except RuntimeError: + # invalid alembic file - probably vrmesh + log.warning("{} is not an alembic file".format(filename)) + return {} root = archive.getTop() iterator = list(root.children) @@ -201,9 +206,7 @@ def load_look(version_id): with avalon.maya.maintained_selection(): container_node = api.load(loader, look_representation) - # Get container members - shader_nodes = cmds.sets(container_node, query=True) - return shader_nodes + return cmds.sets(container_node, query=True) def get_latest_version(asset_id, subset): diff --git a/openpype/tools/mayalookassigner/widgets.py b/openpype/tools/mayalookassigner/widgets.py index 625e9ef8c6..c78fcc460e 100644 --- a/openpype/tools/mayalookassigner/widgets.py +++ b/openpype/tools/mayalookassigner/widgets.py @@ -20,7 +20,6 @@ MODELINDEX = QtCore.QModelIndex() class AssetOutliner(QtWidgets.QWidget): - refreshed = QtCore.Signal() selection_changed = QtCore.Signal() @@ -84,14 +83,13 @@ class AssetOutliner(QtWidgets.QWidget): """ selection_model = self.view.selectionModel() - items = [row.data(TreeModel.ItemRole) for row in - selection_model.selectedRows(0)] - - return items + return [row.data(TreeModel.ItemRole) + for row in selection_model.selectedRows(0)] def get_all_assets(self): """Add all items from the current scene""" + items = [] with lib.preserve_expanded_rows(self.view): with lib.preserve_selection(self.view): self.clear() @@ -118,7 +116,7 @@ class AssetOutliner(QtWidgets.QWidget): # Collect all nodes by hash (optimization) if not selection: - nodes = cmds.ls(dag=True, long=True) + nodes = cmds.ls(dag=True, long=True) else: nodes = commands.get_selected_nodes() id_nodes = commands.create_asset_id_hash(nodes) @@ -187,7 +185,6 @@ class AssetOutliner(QtWidgets.QWidget): class LookOutliner(QtWidgets.QWidget): - menu_apply_action = QtCore.Signal() def __init__(self, parent=None): @@ -236,10 +233,8 @@ class LookOutliner(QtWidgets.QWidget): list: list of dictionaries """ - datas = [i.data(TreeModel.ItemRole) for i in self.view.get_indices()] - items = [d for d in datas if d is not None] # filter Nones - - return items + items = [i.data(TreeModel.ItemRole) for i in self.view.get_indices()] + return [item for item in items if item is not None] def right_mouse_menu(self, pos): """Build RMB menu for look view""" diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 0c02872b4c..1c3ec089f6 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -124,12 +124,12 @@ class HierarchyModel(QtCore.QAbstractItemModel): Main part of ProjectManager. Model should be able to load existing entities, create new, handle their - validations like name duplication and validate if is possible to save it's + validations like name duplication and validate if is possible to save its data. Args: dbcon (AvalonMongoDB): Connection to MongoDB with set AVALON_PROJECT in - it's Session to current project. + its Session to current project. """ # Definition of all possible columns with their labels in default order @@ -799,7 +799,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): for row in range(parent_item.rowCount()): child_item = parent_item.child(row) child_id = child_item.id - # Not sure if this can happend + # Not sure if this can happen # TODO validate this line it seems dangerous as start/end # row is not changed if child_id not in children: @@ -1902,7 +1902,7 @@ class AssetItem(BaseItem): return self._data["name"] def child_parents(self): - """Chilren AssetItem can use this method to get it's parent names. + """Children AssetItem can use this method to get it's parent names. This is used for `data.parents` key on document. """ @@ -2006,7 +2006,7 @@ class AssetItem(BaseItem): @classmethod def data_from_doc(cls, asset_doc): """Convert asset document from Mongo to item data.""" - # Minimum required data for cases that it is new AssetItem withoud doc + # Minimum required data for cases that it is new AssetItem without doc data = { "name": None, "type": "asset" @@ -2253,7 +2253,7 @@ class TaskItem(BaseItem): """Item representing Task item on Asset document. Always should be AssetItem children and never should have any other - childrens. + children. It's name value should be validated with it's parent which only knows if has same name as other sibling under same parent. diff --git a/openpype/tools/project_manager/project_manager/multiselection_combobox.py b/openpype/tools/project_manager/project_manager/multiselection_combobox.py index b26976d3c6..890567de6d 100644 --- a/openpype/tools/project_manager/project_manager/multiselection_combobox.py +++ b/openpype/tools/project_manager/project_manager/multiselection_combobox.py @@ -110,7 +110,7 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): elif event.type() == QtCore.QEvent.KeyPress: # TODO: handle QtCore.Qt.Key_Enter, Key_Return? if event.key() == QtCore.Qt.Key_Space: - # toogle the current items check state + # toggle the current items check state if ( index_flags & QtCore.Qt.ItemIsUserCheckable and index_flags & QtCore.Qt.ItemIsTristate diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 860c009f15..3dd1bd6dc9 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -555,7 +555,7 @@ class PublisherController: self.create_context.reset_avalon_context() self._reset_plugins() - # Publish part must be resetted after plugins + # Publish part must be reset after plugins self._reset_publish() self._reset_instances() @@ -690,7 +690,7 @@ class PublisherController: def remove_instances(self, instances): """""" - # QUESTION Expect that instaces are really removed? In that case save + # QUESTION Expect that instances are really removed? In that case save # reset is not required and save changes too. self.save_changes() diff --git a/openpype/tools/publisher/widgets/border_label_widget.py b/openpype/tools/publisher/widgets/border_label_widget.py index 3d49af410a..696a9050b8 100644 --- a/openpype/tools/publisher/widgets/border_label_widget.py +++ b/openpype/tools/publisher/widgets/border_label_widget.py @@ -51,7 +51,7 @@ class _HBottomLineWidget(QtWidgets.QWidget): Corners may have curve set by radius (`set_radius`). Radius should expect height of widget. - Bottom line is drawed at the bottom of widget. If radius is 0 then height + Bottom line is drawn at the bottom of widget. If radius is 0 then height of widget should be 1px. It is expected that parent widget will set height and radius. @@ -94,7 +94,7 @@ class _HTopCornerLineWidget(QtWidgets.QWidget): or ```┌───────``` - Horizontal line is drawed in the middle of widget. + Horizontal line is drawn in the middle of widget. Widget represents left or right corner. Corner may have curve set by radius (`set_radius`). Radius should expect height of widget (maximum half @@ -225,7 +225,7 @@ class BorderedLabelWidget(QtWidgets.QFrame): self._radius = radius side_width = 1 + radius - # Dont't use fixed width/height as that would set also set + # Don't use fixed width/height as that would set also set # the other size (When fixed width is set then is also set # fixed height). self._left_w.setMinimumWidth(side_width) diff --git a/openpype/tools/publisher/widgets/card_view_widgets.py b/openpype/tools/publisher/widgets/card_view_widgets.py index 271d06e94c..ff0dfc95ab 100644 --- a/openpype/tools/publisher/widgets/card_view_widgets.py +++ b/openpype/tools/publisher/widgets/card_view_widgets.py @@ -388,7 +388,7 @@ class InstanceCardView(AbstractInstanceView): def sizeHint(self): """Modify sizeHint based on visibility of scroll bars.""" - # Calculate width hint by content widget and verticall scroll bar + # Calculate width hint by content widget and vertical scroll bar scroll_bar = self._scroll_area.verticalScrollBar() width = ( self._content_widget.sizeHint().width() diff --git a/openpype/tools/publisher/widgets/list_view_widgets.py b/openpype/tools/publisher/widgets/list_view_widgets.py index 4b2082e523..23a86cd070 100644 --- a/openpype/tools/publisher/widgets/list_view_widgets.py +++ b/openpype/tools/publisher/widgets/list_view_widgets.py @@ -6,7 +6,7 @@ attribute on instance (Group defined by creator). Each item can be enabled/disabled with their checkbox, whole group can be enabled/disabled with checkbox on group or selection can be enabled disabled using checkbox or keyboard key presses: -- Space - change state of selection to oposite +- Space - change state of selection to opposite - Enter - enable selection - Backspace - disable selection @@ -589,7 +589,7 @@ class InstanceListView(AbstractInstanceView): # - create new instance, update existing and remove not existing for group_name, group_item in self._group_items.items(): # Instance items to remove - # - will contain all exising instance ids at the start + # - will contain all existing instance ids at the start # - instance ids may be removed when existing instances are checked to_remove = set() # Mapping of existing instances under group item @@ -659,7 +659,7 @@ class InstanceListView(AbstractInstanceView): for instance_id in to_remove: idx_to_remove.append(existing_mapping[instance_id]) - # Remove them in reverse order to prevend row index changes + # Remove them in reverse order to prevent row index changes for idx in reversed(sorted(idx_to_remove)): group_item.removeRows(idx, 1) diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index 09e56d64cc..28b3c3f95d 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -276,7 +276,7 @@ class VerticallScrollArea(QtWidgets.QScrollArea): The biggest difference is that the scroll area has scroll bar on left side and resize of content will also resize scrollarea itself. - Resize if deffered by 100ms because at the moment of resize are not yet + Resize if deferred by 100ms because at the moment of resize are not yet propagated sizes and visibility of scroll bars. """ def __init__(self, *args, **kwargs): diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 2ebcf73d4e..a85fea9cbc 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -749,7 +749,7 @@ class TasksCombobox(QtWidgets.QComboBox): self.value_changed.emit() def set_text(self, text): - """Set context shown in combobox without chaning selected items.""" + """Set context shown in combobox without changing selected items.""" if text == self._text: return @@ -1000,7 +1000,7 @@ class VariantInputWidget(PlaceholderLineEdit): self.value_changed.emit() def reset_to_origin(self): - """Set origin value of selected instnaces.""" + """Set origin value of selected instances.""" self.set_value(self._origin_value) def get_value(self): @@ -1105,7 +1105,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): Subset name is or may be affected on context. Gives abiity to modify context and subset name of instance. This change is not autopromoted but - must be submited. + must be submitted. Warning: Until artist hit `Submit` changes must not be propagated to instance data. @@ -1179,7 +1179,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): self.cancel_btn = cancel_btn def _on_submit(self): - """Commit changes for selected instnaces.""" + """Commit changes for selected instances.""" variant_value = None asset_name = None task_name = None @@ -1363,7 +1363,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): self._attr_def_id_to_instances = {} self._attr_def_id_to_attr_def = {} - # To store content of scroll area to prevend garbage collection + # To store content of scroll area to prevent garbage collection self._content_widget = None def set_instances_valid(self, valid): @@ -1375,7 +1375,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): self._content_widget.setEnabled(valid) def set_current_instances(self, instances): - """Set current instances for which are attribute definitons shown.""" + """Set current instances for which are attribute definitions shown.""" prev_content_widget = self._scroll_area.widget() if prev_content_widget: self._scroll_area.takeWidget() @@ -1461,7 +1461,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): self._attr_def_id_to_attr_def = {} self._attr_def_id_to_plugin_name = {} - # Store content of scroll area to prevend garbage collection + # Store content of scroll area to prevent garbage collection self._content_widget = None def set_instances_valid(self, valid): @@ -1473,7 +1473,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): self._content_widget.setEnabled(valid) def set_current_instances(self, instances, context_selected): - """Set current instances for which are attribute definitons shown.""" + """Set current instances for which are attribute definitions shown.""" prev_content_widget = self._scroll_area.widget() if prev_content_widget: self._scroll_area.takeWidget() diff --git a/openpype/tools/pyblish_pype/control.py b/openpype/tools/pyblish_pype/control.py index d2b74e316a..64a7f193b4 100644 --- a/openpype/tools/pyblish_pype/control.py +++ b/openpype/tools/pyblish_pype/control.py @@ -106,14 +106,14 @@ class Controller(QtCore.QObject): # ??? Emitted for each process was_processed = QtCore.Signal(dict) - # Emmited when reset + # Emitted when reset # - all data are reset (plugins, processing, pari yielder, etc.) was_reset = QtCore.Signal() - # Emmited when previous group changed + # Emitted when previous group changed passed_group = QtCore.Signal(object) - # Emmited when want to change state of instances + # Emitted when want to change state of instances switch_toggleability = QtCore.Signal(bool) # On action finished @@ -322,7 +322,7 @@ class Controller(QtCore.QObject): try: result = pyblish.plugin.process(plugin, self.context, instance) # Make note of the order at which the - # potential error error occured. + # potential error error occurred. if result["error"] is not None: self.processing["ordersWithError"].add(plugin.order) @@ -564,7 +564,7 @@ class Controller(QtCore.QObject): case must be taken to ensure there are no memory leaks. Explicitly deleting objects shines a light on where objects may still be referenced in the form of an error. No errors - means this was uneccesary, but that's ok. + means this was unnecessary, but that's ok. """ for instance in self.context: diff --git a/openpype/tools/pyblish_pype/util.py b/openpype/tools/pyblish_pype/util.py index 0d581f17af..d3d76b187c 100644 --- a/openpype/tools/pyblish_pype/util.py +++ b/openpype/tools/pyblish_pype/util.py @@ -218,7 +218,7 @@ class OrderGroups: def sort_groups(_groups_dict): sorted_dict = collections.OrderedDict() - # make sure wont affect any dictionary as pointer + # make sure won't affect any dictionary as pointer groups_dict = copy.deepcopy(_groups_dict) last_order = None if None in groups_dict: diff --git a/openpype/tools/sceneinventory/switch_dialog.py b/openpype/tools/sceneinventory/switch_dialog.py index 75e2b6be40..4946c073d4 100644 --- a/openpype/tools/sceneinventory/switch_dialog.py +++ b/openpype/tools/sceneinventory/switch_dialog.py @@ -558,7 +558,7 @@ class SwitchAssetDialog(QtWidgets.QDialog): repre_docs = io.find( { - "type": "rerpesentation", + "type": "representation", "parent": subset_doc["_id"], "name": {"$in": list(repre_names)} }, diff --git a/openpype/tools/sceneinventory/window.py b/openpype/tools/sceneinventory/window.py index e71af6a93d..e363a99d07 100644 --- a/openpype/tools/sceneinventory/window.py +++ b/openpype/tools/sceneinventory/window.py @@ -125,7 +125,7 @@ class SceneInventoryWindow(QtWidgets.QDialog): Override keyPressEvent to do nothing so that Maya's panels won't take focus when pressing "SHIFT" whilst mouse is over viewport or - outliner. This way users don't accidently perform Maya commands + outliner. This way users don't accidentally perform Maya commands whilst trying to name an instance. """ diff --git a/openpype/tools/settings/local_settings/constants.py b/openpype/tools/settings/local_settings/constants.py index 7a8774467f..1836c579af 100644 --- a/openpype/tools/settings/local_settings/constants.py +++ b/openpype/tools/settings/local_settings/constants.py @@ -5,7 +5,7 @@ LABEL_REMOVE_PROJECT = "Remove from project" LABEL_ADD_PROJECT = "Add to project" LABEL_DISCARD_CHANGES = "Discard changes" -# Local setting contants +# Local setting constants # TODO move to settings constants LOCAL_GENERAL_KEY = "general" LOCAL_PROJECTS_KEY = "projects" diff --git a/openpype/tools/settings/local_settings/projects_widget.py b/openpype/tools/settings/local_settings/projects_widget.py index da45467a4e..30a0d212f0 100644 --- a/openpype/tools/settings/local_settings/projects_widget.py +++ b/openpype/tools/settings/local_settings/projects_widget.py @@ -126,7 +126,7 @@ class DynamicInputItem(QtCore.QObject): return "studio" else: if current_value: - return "overriden" + return "overridden" if self.value_item.default_value: return "studio" @@ -512,7 +512,7 @@ class _SiteCombobox(QtWidgets.QWidget): return "studio" else: if current_value: - return "overriden" + return "overridden" studio_value = self._get_local_settings_item(DEFAULT_PROJECT_KEY) if studio_value: diff --git a/openpype/tools/settings/local_settings/window.py b/openpype/tools/settings/local_settings/window.py index a00bc232f4..3fbf841ce7 100644 --- a/openpype/tools/settings/local_settings/window.py +++ b/openpype/tools/settings/local_settings/window.py @@ -222,7 +222,7 @@ class LocalSettingsWindow(QtWidgets.QWidget): # Do not create local settings widget in init phase as it's using # settings objects that must be OK to be able create this widget # - we want to show dialog if anything goes wrong - # - without reseting nothing is shown + # - without resetting nothing is shown self._settings_widget = None self._scroll_widget = scroll_widget self.reset_btn = reset_btn diff --git a/openpype/tools/settings/settings/README.md b/openpype/tools/settings/settings/README.md index 31d8fc5b74..1c916ddff2 100644 --- a/openpype/tools/settings/settings/README.md +++ b/openpype/tools/settings/settings/README.md @@ -10,7 +10,7 @@ - `"is_file"` - this key is for storing openpype defaults in `openpype` repo - reasons of existence: developing new schemas does not require to create defaults manually - key is validated, must be once in hierarchy else it won't be possible to store openpype defaults - - `"is_group"` - define that all values under key in hierarchy will be overriden if any value is modified, this information is also stored to overrides + - `"is_group"` - define that all values under key in hierarchy will be overridden if any value is modified, this information is also stored to overrides - this keys is not allowed for all inputs as they may have not reason for that - key is validated, can be only once in hierarchy but is not required - currently there are `system configurations` and `project configurations` @@ -199,7 +199,7 @@ - number input, can be used for both integer and float - key `"decimal"` defines how many decimal places will be used, 0 is for integer input (Default: `0`) - key `"minimum"` as minimum allowed number to enter (Default: `-99999`) - - key `"maxium"` as maximum allowed number to enter (Default: `99999`) + - key `"maximum"` as maximum allowed number to enter (Default: `99999`) ``` { "type": "number", diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index e271585852..bbfbc58627 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -93,7 +93,7 @@ class BaseWidget(QtWidgets.QWidget): if is_modified: return "modified" if has_project_override: - return "overriden" + return "overridden" if has_studio_override: return "studio" return "" @@ -168,7 +168,7 @@ class BaseWidget(QtWidgets.QWidget): with self.category_widget.working_state_context(): self.entity.add_to_project_override - action = QtWidgets.QAction("Add to project project override") + action = QtWidgets.QAction("Add to project override") actions_mapping[action] = add_to_project_override menu.addAction(action) @@ -289,7 +289,7 @@ class BaseWidget(QtWidgets.QWidget): action = QtWidgets.QAction("Paste", menu) output.append((action, paste_value)) - # Paste value to matchin entity + # Paste value to matching entity def paste_value_to_path(): with self.category_widget.working_state_context(): _set_entity_value(matching_entity, value) diff --git a/openpype/tools/settings/settings/dict_conditional.py b/openpype/tools/settings/settings/dict_conditional.py index 2e1617f505..b2a7bb52a2 100644 --- a/openpype/tools/settings/settings/dict_conditional.py +++ b/openpype/tools/settings/settings/dict_conditional.py @@ -183,7 +183,7 @@ class DictConditionalWidget(BaseWidget): content_widget.setObjectName("ContentWidget") if self.entity.highlight_content: - content_state = "hightlighted" + content_state = "highlighted" bottom_margin = 5 else: content_state = "" diff --git a/openpype/tools/settings/settings/dict_mutable_widget.py b/openpype/tools/settings/settings/dict_mutable_widget.py index 294711b38a..6489266131 100644 --- a/openpype/tools/settings/settings/dict_mutable_widget.py +++ b/openpype/tools/settings/settings/dict_mutable_widget.py @@ -354,7 +354,7 @@ class ModifiableDictItem(QtWidgets.QWidget): if self.entity.has_unsaved_changes: return "modified" if self.entity.has_project_override: - return "overriden" + return "overridden" if self.entity.has_studio_override: return "studio" return "" @@ -600,8 +600,8 @@ class DictMutableKeysWidget(BaseWidget): self.input_fields = [] self.required_inputs_by_key = {} - if self.entity.hightlight_content: - content_state = "hightlighted" + if self.entity.highlight_content: + content_state = "highlighted" bottom_margin = 5 else: content_state = "" diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 22f672da2b..1ddee7efbe 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -150,7 +150,7 @@ class DictImmutableKeysWidget(BaseWidget): content_widget.setObjectName("ContentWidget") if self.entity.highlight_content: - content_state = "hightlighted" + content_state = "highlighted" bottom_margin = 5 else: content_state = "" @@ -477,7 +477,7 @@ class OpenPypeVersionText(TextWidget): self.entity.set(value) self.update_style() else: - # Manually trigger hierachical style update + # Manually trigger hierarchical style update self.ignore_input_changes.set_ignore(True) self.ignore_input_changes.set_ignore(False) @@ -675,7 +675,7 @@ class RawJsonWidget(InputWidget): self.entity.set(self.input_field.json_value()) self.update_style() else: - # Manually trigger hierachical style update + # Manually trigger hierarchical style update self.ignore_input_changes.set_ignore(True) self.ignore_input_changes.set_ignore(False) @@ -792,7 +792,7 @@ class PathWidget(BaseWidget): self.input_field.hierarchical_style_update() def _on_entity_change(self): - # No need to do anything. Styles will be updated from top hierachy. + # No need to do anything. Styles will be updated from top hierarchy. pass def update_style(self): diff --git a/openpype/tools/settings/settings/lib.py b/openpype/tools/settings/settings/lib.py index d12a14259a..eef157812f 100644 --- a/openpype/tools/settings/settings/lib.py +++ b/openpype/tools/settings/settings/lib.py @@ -7,7 +7,7 @@ VALUE_CHANGE_OFFSET_MS = 300 def create_deffered_value_change_timer(callback): - """Deffer value change callback. + """Defer value change callback. UI won't trigger all callbacks on each value change but after predefined time. Timer is reset on each start so callback is triggered after user diff --git a/openpype/tools/settings/settings/list_strict_widget.py b/openpype/tools/settings/settings/list_strict_widget.py index 046b6992f6..f0a3022a50 100644 --- a/openpype/tools/settings/settings/list_strict_widget.py +++ b/openpype/tools/settings/settings/list_strict_widget.py @@ -28,7 +28,7 @@ class ListStrictWidget(BaseWidget): break self._any_children_has_label = any_children_has_label - # Change column stretch factor for verticall alignment + # Change column stretch factor for vertical alignment if not self.entity.is_horizontal: col_index = 2 if any_children_has_label else 1 content_layout.setColumnStretch(col_index, 1) diff --git a/openpype/tools/settings/settings/multiselection_combobox.py b/openpype/tools/settings/settings/multiselection_combobox.py index 176f4cab8c..c2cc2a8fee 100644 --- a/openpype/tools/settings/settings/multiselection_combobox.py +++ b/openpype/tools/settings/settings/multiselection_combobox.py @@ -131,7 +131,7 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): elif event.type() == QtCore.QEvent.KeyPress: # TODO: handle QtCore.Qt.Key_Enter, Key_Return? if event.key() == QtCore.Qt.Key_Space: - # toogle the current items check state + # toggle the current items check state if ( index_flags & QtCore.Qt.ItemIsUserCheckable and index_flags & QtCore.Qt.ItemIsTristate diff --git a/openpype/tools/settings/settings/window.py b/openpype/tools/settings/settings/window.py index c376e5e91e..411e7b5e7f 100644 --- a/openpype/tools/settings/settings/window.py +++ b/openpype/tools/settings/settings/window.py @@ -153,7 +153,7 @@ class MainWidget(QtWidgets.QWidget): def _on_restart_required(self): # Don't show dialog if there are not registered slots for # `trigger_restart` signal. - # - For example when settings are runnin as standalone tool + # - For example when settings are running as standalone tool # - PySide2 and PyQt5 compatible way how to find out method_index = self.metaObject().indexOfMethod("trigger_restart()") method = self.metaObject().method(method_index) diff --git a/openpype/tools/settings/settings/wrapper_widgets.py b/openpype/tools/settings/settings/wrapper_widgets.py index b14a226912..7370fcf945 100644 --- a/openpype/tools/settings/settings/wrapper_widgets.py +++ b/openpype/tools/settings/settings/wrapper_widgets.py @@ -92,8 +92,7 @@ class CollapsibleWrapper(WrapperWidget): self.content_layout = content_layout if self.collapsible: - if not self.collapsed: - body_widget.toggle_content() + body_widget.toggle_content(self.collapsed) else: body_widget.hide_toolbox(hide_content=False) diff --git a/openpype/tools/standalonepublish/app.py b/openpype/tools/standalonepublish/app.py index 2ce757f773..3630d92c83 100644 --- a/openpype/tools/standalonepublish/app.py +++ b/openpype/tools/standalonepublish/app.py @@ -99,7 +99,7 @@ class Window(QtWidgets.QDialog): return self._db def on_start(self): - ''' Things must be done when initilized. + ''' Things must be done when initialized. ''' # Refresh asset input in Family widget self.on_asset_changed() diff --git a/openpype/tools/standalonepublish/widgets/model_asset.py b/openpype/tools/standalonepublish/widgets/model_asset.py index 44649b3dc3..60afe8f96c 100644 --- a/openpype/tools/standalonepublish/widgets/model_asset.py +++ b/openpype/tools/standalonepublish/widgets/model_asset.py @@ -68,7 +68,7 @@ class AssetModel(TreeModel): """ if silos: # WARNING: Silo item "_id" is set to silo value - # mainly because GUI issue with perserve selection and expanded row + # mainly because GUI issue with preserve selection and expanded row # and because of easier hierarchy parenting (in "assets") for silo in silos: node = Node({ diff --git a/openpype/tools/standalonepublish/widgets/widget_asset.py b/openpype/tools/standalonepublish/widgets/widget_asset.py index f4a4dfe0c4..2886d600bf 100644 --- a/openpype/tools/standalonepublish/widgets/widget_asset.py +++ b/openpype/tools/standalonepublish/widgets/widget_asset.py @@ -18,7 +18,7 @@ def preserve_expanded_rows(tree_view, This function is created to maintain the expand vs collapse status of the model items. When refresh is triggered the items which are expanded - will stay expanded and vise versa. + will stay expanded and vice versa. Arguments: tree_view (QWidgets.QTreeView): the tree view which is @@ -68,7 +68,7 @@ def preserve_selection(tree_view, This function is created to maintain the selection status of the model items. When refresh is triggered the items which are expanded - will stay expanded and vise versa. + will stay expanded and vice versa. tree_view (QWidgets.QTreeView): the tree view nested in the application column (int): the column to retrieve the data from @@ -390,7 +390,7 @@ class AssetWidget(QtWidgets.QWidget): assets, (tuple, list) ), "Assets must be list or tuple" - # convert to list - tuple cant be modified + # convert to list - tuple can't be modified assets = list(assets) # Clear selection diff --git a/openpype/tools/standalonepublish/widgets/widget_drop_frame.py b/openpype/tools/standalonepublish/widgets/widget_drop_frame.py index 7fe43c4203..c1c59d65b6 100644 --- a/openpype/tools/standalonepublish/widgets/widget_drop_frame.py +++ b/openpype/tools/standalonepublish/widgets/widget_drop_frame.py @@ -101,7 +101,7 @@ class DropDataFrame(QtWidgets.QFrame): return paths def _add_item(self, data, actions=[]): - # Assign to self so garbage collector wont remove the component + # Assign to self so garbage collector won't remove the component # during initialization new_component = ComponentItem(self.components_list, self) new_component.set_context(data) diff --git a/openpype/tools/standalonepublish/widgets/widget_family.py b/openpype/tools/standalonepublish/widgets/widget_family.py index 1e20028392..ae44899a89 100644 --- a/openpype/tools/standalonepublish/widgets/widget_family.py +++ b/openpype/tools/standalonepublish/widgets/widget_family.py @@ -373,7 +373,7 @@ class FamilyWidget(QtWidgets.QWidget): Override keyPressEvent to do nothing so that Maya's panels won't take focus when pressing "SHIFT" whilst mouse is over viewport or - outliner. This way users don't accidently perform Maya commands + outliner. This way users don't accidentally perform Maya commands whilst trying to name an instance. """ diff --git a/openpype/tools/tray/pype_info_widget.py b/openpype/tools/tray/pype_info_widget.py index 2ca625f307..8414cefec8 100644 --- a/openpype/tools/tray/pype_info_widget.py +++ b/openpype/tools/tray/pype_info_widget.py @@ -9,7 +9,7 @@ from openpype.api import resources from openpype.settings.lib import get_local_settings from openpype.lib.pype_info import ( get_all_current_info, - get_pype_info, + get_openpype_info, get_workstation_info, extract_pype_info_to_file ) @@ -426,22 +426,29 @@ class PypeInfoSubWidget(QtWidgets.QWidget): """Create widget with information about OpenPype application.""" # Get pype info data - pype_info = get_pype_info() + pype_info = get_openpype_info() # Modify version key/values version_value = "{} ({})".format( pype_info.pop("version", self.not_applicable), pype_info.pop("version_type", self.not_applicable) ) pype_info["version_value"] = version_value - # Prepare lable mapping + # Prepare label mapping key_label_mapping = { - "version_value": "OpenPype version:", + "version_value": "Running version:", + "build_verison": "Build version:", "executable": "OpenPype executable:", "pype_root": "OpenPype location:", "mongo_url": "OpenPype Mongo URL:" } # Prepare keys order - keys_order = ["version_value", "executable", "pype_root", "mongo_url"] + keys_order = [ + "version_value", + "build_verison", + "executable", + "pype_root", + "mongo_url" + ] for key in pype_info.keys(): if key not in keys_order: keys_order.append(key) diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index c9b8aaa842..9d4d0aa31b 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -18,6 +18,7 @@ from openpype.lib import ( get_openpype_execute_args, op_version_control_available, is_current_version_studio_latest, + is_current_version_higher_than_expected, is_running_from_build, is_running_staging, get_expected_version, @@ -84,7 +85,7 @@ class VersionDialog(QtWidgets.QDialog): def __init__(self, parent=None): super(VersionDialog, self).__init__(parent) - self.setWindowTitle("OpenPype update is needed") + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( @@ -104,13 +105,12 @@ class VersionDialog(QtWidgets.QDialog): label_widget.setWordWrap(True) top_layout = QtWidgets.QHBoxLayout(top_widget) - # top_layout.setContentsMargins(0, 0, 0, 0) top_layout.setSpacing(10) top_layout.addWidget(gift_icon_label, 0, QtCore.Qt.AlignCenter) top_layout.addWidget(label_widget, 1) - ignore_btn = QtWidgets.QPushButton("Later", self) - restart_btn = QtWidgets.QPushButton("Restart && Update", self) + ignore_btn = QtWidgets.QPushButton(self) + restart_btn = QtWidgets.QPushButton(self) restart_btn.setObjectName("TrayRestartButton") btns_layout = QtWidgets.QHBoxLayout() @@ -127,7 +127,12 @@ class VersionDialog(QtWidgets.QDialog): restart_btn.clicked.connect(self._on_reset) self._label_widget = label_widget + self._gift_icon_label = gift_icon_label + self._ignore_btn = ignore_btn + self._restart_btn = restart_btn + self._restart_accepted = False + self._current_is_higher = False self.setStyleSheet(style.load_stylesheet()) @@ -152,15 +157,41 @@ class VersionDialog(QtWidgets.QDialog): def closeEvent(self, event): super().closeEvent(event) - if not self._restart_accepted: - self.ignore_requested.emit() + if self._restart_accepted or self._current_is_higher: + return + # Trigger ignore requested only if restart was not clicked and current + # version is lower + self.ignore_requested.emit() - def update_versions(self, current_version, expected_version): - message = ( - "Running OpenPype version is {}." - " Your production has been updated to version {}." - ).format(str(current_version), str(expected_version)) - self._label_widget.setText(message) + def update_versions( + self, current_version, expected_version, current_is_higher + ): + if not current_is_higher: + title = "OpenPype update is needed" + label_message = ( + "Running OpenPype version is {}." + " Your production has been updated to version {}." + ).format(str(current_version), str(expected_version)) + ignore_label = "Later" + restart_label = "Restart && Update" + else: + title = "OpenPype version is higher" + label_message = ( + "Running OpenPype version is {}." + " Your production uses version {}." + ).format(str(current_version), str(expected_version)) + ignore_label = "Ignore" + restart_label = "Restart && Change" + + self.setWindowTitle(title) + + self._current_is_higher = current_is_higher + + self._gift_icon_label.setVisible(not current_is_higher) + + self._label_widget.setText(label_message) + self._ignore_btn.setText(ignore_label) + self._restart_btn.setText(restart_label) def _on_ignore(self): self.reject() @@ -207,7 +238,7 @@ class TrayManager: @property def doubleclick_callback(self): - """Doubleclick callback for Tray icon.""" + """Double-click callback for Tray icon.""" callback_name = self.modules_manager.doubleclick_callback return self.modules_manager.doubleclick_callbacks.get(callback_name) @@ -227,6 +258,10 @@ class TrayManager: def validate_openpype_version(self): using_requested = is_current_version_studio_latest() + # TODO Handle situations when version can't be detected + if using_requested is None: + using_requested = True + self._restart_action.setVisible(not using_requested) if using_requested: if ( @@ -247,15 +282,17 @@ class TrayManager: expected_version = get_expected_version() current_version = get_openpype_version() + current_is_higher = is_current_version_higher_than_expected() + self._version_dialog.update_versions( - current_version, expected_version + current_version, expected_version, current_is_higher ) self._version_dialog.show() self._version_dialog.raise_() self._version_dialog.activateWindow() def _restart_and_install(self): - self.restart() + self.restart(use_expected_version=True) def _outdated_version_ignored(self): self.show_tray_message( @@ -328,8 +365,8 @@ class TrayManager: self.main_thread_timer = main_thread_timer version_check_timer = QtCore.QTimer() - version_check_timer.timeout.connect(self._on_version_check_timer) if self._version_check_interval > 0: + version_check_timer.timeout.connect(self._on_version_check_timer) version_check_timer.setInterval(self._version_check_interval) version_check_timer.start() self._version_check_timer = version_check_timer @@ -341,6 +378,9 @@ class TrayManager: def _startup_validations(self): """Run possible startup validations.""" + # Trigger version validation on start + self._version_check_timer.timeout.emit() + self._validate_settings_defaults() def _validate_settings_defaults(self): @@ -358,7 +398,7 @@ class TrayManager: title = "Settings miss default values" msg = ( "Your OpenPype will not work as expected! \n" - "Some default values in settigs are missing. \n\n" + "Some default values in settings are missing. \n\n" "Please contact OpenPype team." ) msg_box = QtWidgets.QMessageBox( @@ -390,7 +430,7 @@ class TrayManager: message (str): Content of message. icon (QSystemTrayIcon.MessageIcon): Message's icon. Default is Information icon, may differ by Qt version. - msecs (int): Duration of message visibility in miliseconds. + msecs (int): Duration of message visibility in milliseconds. Default is 10000 msecs, may differ by Qt version. """ args = [title, message] @@ -429,12 +469,18 @@ class TrayManager: self._restart_action = restart_action def _on_restart_action(self): - self.restart() + self.restart(use_expected_version=True) - def restart(self, reset_version=True): + def restart(self, use_expected_version=False, reset_version=False): """Restart Tray tool. First creates new process with same argument and close current tray. + + Args: + use_expected_version(bool): OpenPype version is set to expected + version. + reset_version(bool): OpenPype version is cleaned up so igniters + logic will decide which version will be used. """ args = get_openpype_execute_args() kwargs = { @@ -448,6 +494,15 @@ class TrayManager: if args[-1] == additional_args[0]: additional_args.pop(0) + if use_expected_version: + expected_version = get_expected_version() + if expected_version is not None: + reset_version = False + kwargs["env"]["OPENPYPE_VERSION"] = str(expected_version) + else: + # Trigger reset of version if expected version was not found + reset_version = True + # Pop OPENPYPE_VERSION if reset_version: # Add staging flag if was running from staging diff --git a/openpype/tools/tray_app/app.py b/openpype/tools/tray_app/app.py index f1363d0cab..0005c6fb94 100644 --- a/openpype/tools/tray_app/app.py +++ b/openpype/tools/tray_app/app.py @@ -268,7 +268,7 @@ class ConsoleTrayApp: def _multiple_replace(text, adict): """Replace multiple tokens defined in dict. - Find and replace all occurances of strings defined in dict is + Find and replace all occurrences of strings defined in dict is supplied string. Args: diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index 60c9e79829..a7ad8fef3b 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -1,6 +1,6 @@ """Single access point to all tools usable in hosts. -It is possible to create `HostToolsHelper` in host implementaion or +It is possible to create `HostToolsHelper` in host implementation or use singleton approach with global functions (using helper anyway). """ @@ -223,7 +223,7 @@ class HostToolsHelper: """Dialog of experimental tools. For some hosts it is not easy to modify menu of tools. For - those cases was addded experimental tools dialog which is Qt based + those cases was added experimental tools dialog which is Qt based and can dynamically filled by experimental tools so host need only single "Experimental tools" button to see them. @@ -347,7 +347,7 @@ class _SingletonPoint: return cls.helper.get_tool_by_name(tool_name, parent, *args, **kwargs) -# Function callbacks using singleton acces point +# Function callbacks using singleton access point def get_tool_by_name(tool_name, parent=None, *args, **kwargs): return _SingletonPoint.get_tool_by_name(tool_name, parent, *args, **kwargs) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 5f3456ae3e..84156fce0d 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -132,7 +132,7 @@ def preserve_expanded_rows(tree_view, column=0, role=None): This function is created to maintain the expand vs collapse status of the model items. When refresh is triggered the items which are expanded - will stay expanded and vise versa. + will stay expanded and vice versa. Arguments: tree_view (QWidgets.QTreeView): the tree view which is @@ -176,7 +176,7 @@ def preserve_selection(tree_view, column=0, role=None, current_index=True): This function is created to maintain the selection status of the model items. When refresh is triggered the items which are expanded - will stay expanded and vise versa. + will stay expanded and vice versa. tree_view (QWidgets.QTreeView): the tree view nested in the application column (int): the column to retrieve the data from @@ -231,6 +231,7 @@ class FamilyConfigCache: self.dbcon = dbcon self.family_configs = {} self._family_filters_set = False + self._family_filters_is_include = True self._require_refresh = True @classmethod @@ -252,7 +253,7 @@ class FamilyConfigCache: "icon": self.default_icon() } if self._family_filters_set: - item["state"] = False + item["state"] = not self._family_filters_is_include return item def refresh(self, force=False): @@ -316,20 +317,23 @@ class FamilyConfigCache: matching_item = filter_profiles(profiles, profiles_filter) families = [] + is_include = True if matching_item: families = matching_item["filter_families"] + is_include = matching_item["is_include"] if not families: return self._family_filters_set = True + self._family_filters_is_include = is_include # Replace icons with a Qt icon we can use in the user interfaces for family in families: family_info = { "name": family, "icon": self.default_icon(), - "state": True + "state": is_include } self.family_configs[family] = family_info @@ -368,7 +372,7 @@ class GroupsConfig: group_configs = [] project_name = self.dbcon.Session.get("AVALON_PROJECT") if project_name: - # Get pre-defined group name and apperance from project config + # Get pre-defined group name and appearance from project config project_doc = self.dbcon.find_one( {"type": "project"}, projection={"config.groups": True} diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index c32eae043e..e82bced927 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -152,7 +152,7 @@ class OptionalMenu(QtWidgets.QMenu): """A subclass of `QtWidgets.QMenu` to work with `OptionalAction` This menu has reimplemented `mouseReleaseEvent`, `mouseMoveEvent` and - `leaveEvent` to provide better action hightlighting and triggering for + `leaveEvent` to provide better action highlighting and triggering for actions that were instances of `QtWidgets.QWidgetAction`. """ diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index f4a86050cb..b7f9ff8786 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -11,6 +11,7 @@ from Qt import QtWidgets, QtCore from avalon import io, api, pipeline from openpype import style +from openpype.pipeline.lib import BeforeWorkfileSave from openpype.tools.utils.lib import ( qt_app_context ) @@ -162,7 +163,7 @@ class NameWindow(QtWidgets.QDialog): # Build inputs inputs_layout = QtWidgets.QFormLayout(inputs_widget) - # Add version only if template contain version key + # Add version only if template contains version key # - since the version can be padded with "{version:0>4}" we only search # for "{version". if "{version" in self.template: @@ -170,7 +171,7 @@ class NameWindow(QtWidgets.QDialog): else: version_widget.setVisible(False) - # Add subversion only if template containt `{comment}` + # Add subversion only if template contains `{comment}` if "{comment}" in self.template: inputs_layout.addRow("Subversion:", subversion_input) else: @@ -183,7 +184,7 @@ class NameWindow(QtWidgets.QDialog): main_layout.addWidget(inputs_widget) main_layout.addWidget(btns_widget) - # Singal callback registration + # Signal callback registration version_input.valueChanged.connect(self.on_version_spinbox_changed) last_version_check.stateChanged.connect( self.on_version_checkbox_changed @@ -367,7 +368,8 @@ class FilesWidget(QtWidgets.QWidget): self.template_key = "work" # This is not root but workfile directory - self.root = None + self._workfiles_root = None + self._workdir_path = None self.host = api.registered_host() # Whether to automatically select the latest modified @@ -465,8 +467,9 @@ class FilesWidget(QtWidgets.QWidget): # This way we can browse it even before we enter it. if self._asset_id and self._task_name and self._task_type: session = self._get_session() - self.root = self.host.work_root(session) - self.files_model.set_root(self.root) + self._workdir_path = session["AVALON_WORKDIR"] + self._workfiles_root = self.host.work_root(session) + self.files_model.set_root(self._workfiles_root) else: self.files_model.set_root(None) @@ -590,7 +593,7 @@ class FilesWidget(QtWidgets.QWidget): window = NameWindow( parent=self, - root=self.root, + root=self._workfiles_root, anatomy=self.anatomy, template_key=self.template_key, session=session @@ -605,7 +608,7 @@ class FilesWidget(QtWidgets.QWidget): return src = self._get_selected_filepath() - dst = os.path.join(self.root, work_file) + dst = os.path.join(self._workfiles_root, work_file) shutil.copy(src, dst) self.workfile_created.emit(dst) @@ -638,98 +641,59 @@ class FilesWidget(QtWidgets.QWidget): "filter": ext_filter } if Qt.__binding__ in ("PySide", "PySide2"): - kwargs["dir"] = self.root + kwargs["dir"] = self._workfiles_root else: - kwargs["directory"] = self.root + kwargs["directory"] = self._workfiles_root work_file = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0] if work_file: self.open_file(work_file) def on_save_as_pressed(self): - work_file = self.get_filename() - if not work_file: + work_filename = self.get_filename() + if not work_filename: return - # Initialize work directory if it has not been initialized before - if not os.path.exists(self.root): - log.debug("Initializing Work Directory: %s", self.root) - self.initialize_work_directory() - if not os.path.exists(self.root): - # Failed to initialize Work Directory - log.error( - "Failed to initialize Work Directory: {}".format(self.root) - ) - return - - file_path = os.path.join(os.path.normpath(self.root), work_file) - - pipeline.emit("before.workfile.save", [file_path]) - - self._enter_session() # Make sure we are in the right session - self.host.save_file(file_path) + # Trigger before save event + BeforeWorkfileSave.emit(work_filename, self._workdir_path) + # Make sure workfiles root is updated + # - this triggers 'workio.work_root(...)' which may change value of + # '_workfiles_root' self.set_asset_task( self._asset_id, self._task_name, self._task_type ) + + # Create workfiles root folder + if not os.path.exists(self._workfiles_root): + log.debug("Initializing Work Directory: %s", self._workfiles_root) + os.makedirs(self._workfiles_root) + + # Update session if context has changed + self._enter_session() + # Prepare full path to workfile and save it + filepath = os.path.join( + os.path.normpath(self._workfiles_root), work_filename + ) + self.host.save_file(filepath) + # Create extra folders create_workdir_extra_folders( - self.root, + self._workdir_path, api.Session["AVALON_APP"], self._task_type, self._task_name, api.Session["AVALON_PROJECT"] ) - pipeline.emit("after.workfile.save", [file_path]) - - self.workfile_created.emit(file_path) + # Trigger after save events + pipeline.emit("after.workfile.save", [filepath]) + self.workfile_created.emit(filepath) + # Refresh files model self.refresh() def on_file_select(self): self.file_selected.emit(self._get_selected_filepath()) - def initialize_work_directory(self): - """Initialize Work Directory. - - This is used when the Work Directory does not exist yet. - - This finds the current AVALON_APP_NAME and tries to triggers its - `.toml` initialization step. Note that this will only be valid - whenever `AVALON_APP_NAME` is actually set in the current session. - - """ - - # Inputs (from the switched session and running app) - session = api.Session.copy() - changes = pipeline.compute_session_changes( - session, - asset=self._get_asset_doc(), - task=self._task_name, - template_key=self.template_key - ) - session.update(changes) - - # Prepare documents to get workdir data - project_doc = io.find_one({"type": "project"}) - asset_doc = io.find_one( - { - "type": "asset", - "name": session["AVALON_ASSET"] - } - ) - task_name = session["AVALON_TASK"] - host_name = session["AVALON_APP"] - - # Get workdir from collected documents - workdir = get_workdir(project_doc, asset_doc, task_name, host_name) - # Create workdir if does not exist yet - if not os.path.exists(workdir): - os.makedirs(workdir) - - # Force a full to the asset as opposed to just self.refresh() so - # that it will actually check again whether the Work directory exists - self.set_asset_task(self._asset_id, self._task_name, self._task_type) - def refresh(self): """Refresh listed files for current selection in the interface""" self.files_model.refresh() @@ -833,7 +797,7 @@ class SidePanelWidget(QtWidgets.QWidget): self.note_input.setEnabled(enabled) self.btn_note_save.setEnabled(enabled) - # Make sure workfile doc is overriden + # Make sure workfile doc is overridden self._workfile_doc = workfile_doc # Disable inputs and remove texts if any required arguments are missing if not enabled: @@ -978,7 +942,7 @@ class Window(QtWidgets.QMainWindow): Override keyPressEvent to do nothing so that Maya's panels won't take focus when pressing "SHIFT" whilst mouse is over viewport or - outliner. This way users don't accidently perform Maya commands + outliner. This way users don't accidentally perform Maya commands whilst trying to name an instance. """ diff --git a/openpype/version.py b/openpype/version.py index 121bb01e8f..c573c57637 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.8.0-nightly.5" +__version__ = "3.8.1" diff --git a/poetry.lock b/poetry.lock index f513b76611..4d5ba5407b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -302,11 +302,6 @@ python-versions = ">=3.6" cx-logging = {version = ">=3.0", markers = "sys_platform == \"win32\""} importlib-metadata = ">=4.3.1" -[package.source] -type = "legacy" -url = "https://distribute.openpype.io/wheels" -reference = "openpype" - [[package]] name = "cx-logging" version = "3.0" @@ -789,11 +784,11 @@ six = "*" [[package]] name = "pillow" -version = "8.4.0" +version = "9.0.0" description = "Python Imaging Library (Fork)" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "platformdirs" @@ -1286,8 +1281,8 @@ python-versions = "*" [[package]] name = "speedcopy" -version = "2.1.0" -description = "Replacement or alternative for python copyfile()utilizing server side copy on network shares for fastercopying." +version = "2.1.2" +description = "Replacement or alternative for python copyfile() utilizing server side copy on network shares for faster copying." category = "main" optional = false python-versions = "*" @@ -1580,7 +1575,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "3.7.*" -content-hash = "877c1c6292735f495d915fc6aa85450eb20fc63f266a9c6bf7ba1125af3579a5" +content-hash = "c933f867533b4ca36b9af3002395bdf6c0de6904a8bbcc38578840b266c54872" [metadata.files] acre = [] @@ -1668,10 +1663,13 @@ babel = [ {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, ] bcrypt = [ + {file = "bcrypt-3.2.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b589229207630484aefe5899122fb938a5b017b0f4349f769b8c13e78d99a8fd"}, {file = "bcrypt-3.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c95d4cbebffafcdd28bd28bb4e25b31c50f6da605c81ffd9ad8a3d1b2ab7b1b6"}, {file = "bcrypt-3.2.0-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:63d4e3ff96188e5898779b6057878fecf3f11cfe6ec3b313ea09955d587ec7a7"}, {file = "bcrypt-3.2.0-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1"}, {file = "bcrypt-3.2.0-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d"}, + {file = "bcrypt-3.2.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a0584a92329210fcd75eb8a3250c5a941633f8bfaf2a18f81009b097732839b7"}, + {file = "bcrypt-3.2.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:56e5da069a76470679f312a7d3d23deb3ac4519991a0361abc11da837087b61d"}, {file = "bcrypt-3.2.0-cp36-abi3-win32.whl", hash = "sha256:a67fb841b35c28a59cebed05fbd3e80eea26e6d75851f0574a9273c80f3e9b55"}, {file = "bcrypt-3.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:81fec756feff5b6818ea7ab031205e1d323d8943d237303baca2c5f9c7846f34"}, {file = "bcrypt-3.2.0.tar.gz", hash = "sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29"}, @@ -1821,7 +1819,17 @@ cryptography = [ {file = "cryptography-35.0.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:1ed82abf16df40a60942a8c211251ae72858b25b7421ce2497c2eb7a1cee817c"}, {file = "cryptography-35.0.0.tar.gz", hash = "sha256:9933f28f70d0517686bd7de36166dda42094eac49415459d9bdf5e7df3e0086d"}, ] -cx-freeze = [] +cx-freeze = [ + {file = "cx_Freeze-6.7-cp36-cp36m-win32.whl", hash = "sha256:befd5a2fb915eae03bfb237ffebeb7a5dfe8f58bc8c93c18f19a47c1fc800d8a"}, + {file = "cx_Freeze-6.7-cp36-cp36m-win_amd64.whl", hash = "sha256:7718801da4a3ad499b0d648c507759f3b7ffef24ba4e9fd8ff3b129b77dda0e3"}, + {file = "cx_Freeze-6.7-cp37-cp37m-win32.whl", hash = "sha256:361f27b1575b508a52fe3eb0afe83f2594c44235d084e04815924ea48742f0ea"}, + {file = "cx_Freeze-6.7-cp37-cp37m-win_amd64.whl", hash = "sha256:65aec3b37b91b0a41ccf61b9794d8660b809d665a529479489ab410a201736fc"}, + {file = "cx_Freeze-6.7-cp38-cp38-win32.whl", hash = "sha256:ec3bb21bc74c4fea458563f6e47191daa5486f089aebb1d00a922b5aa9834c87"}, + {file = "cx_Freeze-6.7-cp38-cp38-win_amd64.whl", hash = "sha256:1b76c72f8a2c0e1a416b7bced9d5fd247a0120f6b1b35d4678568beb1ccf9380"}, + {file = "cx_Freeze-6.7-cp39-cp39-win32.whl", hash = "sha256:beb38b2df37af44d08a1e62f09f0e1c5b991ded751f7cc3ab0ac93fba013ba54"}, + {file = "cx_Freeze-6.7-cp39-cp39-win_amd64.whl", hash = "sha256:967c86416b8dea3b00c0f1e2c94f5118004c9297afc8b1ae88002aec28a9097c"}, + {file = "cx_Freeze-6.7.tar.gz", hash = "sha256:050f1dd133a04810bd7f38ac7ae3b290054acb2ff4f6e73f7a286266d153495d"}, +] cx-logging = [ {file = "cx_Logging-3.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:9fcd297e5c51470521c47eff0f86ba844aeca6be97e13c3e2114ebdf03fa3c96"}, {file = "cx_Logging-3.0-cp36-cp36m-win32.whl", hash = "sha256:0df4be47c5022cc54316949e283403214568ef599817ced0c0972183d6d4fabb"}, @@ -1972,12 +1980,28 @@ log4mongo = [ {file = "log4mongo-1.7.0.tar.gz", hash = "sha256:dc374617206162a0b14167fbb5feac01dbef587539a235dadba6200362984a68"}, ] markupsafe = [ + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, @@ -1986,14 +2010,27 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, @@ -2003,6 +2040,12 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, @@ -2103,47 +2146,38 @@ pathlib2 = [ {file = "pathlib2-2.3.6.tar.gz", hash = "sha256:7d8bcb5555003cdf4a8d2872c538faa3a0f5d20630cb360e518ca3b981795e5f"}, ] pillow = [ - {file = "Pillow-8.4.0-cp310-cp310-macosx_10_10_universal2.whl", hash = "sha256:81f8d5c81e483a9442d72d182e1fb6dcb9723f289a57e8030811bac9ea3fef8d"}, - {file = "Pillow-8.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3f97cfb1e5a392d75dd8b9fd274d205404729923840ca94ca45a0af57e13dbe6"}, - {file = "Pillow-8.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb9fc393f3c61f9054e1ed26e6fe912c7321af2f41ff49d3f83d05bacf22cc78"}, - {file = "Pillow-8.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d82cdb63100ef5eedb8391732375e6d05993b765f72cb34311fab92103314649"}, - {file = "Pillow-8.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc1afda735a8d109007164714e73771b499768b9bb5afcbbee9d0ff374b43f"}, - {file = "Pillow-8.4.0-cp310-cp310-win32.whl", hash = "sha256:e3dacecfbeec9a33e932f00c6cd7996e62f53ad46fbe677577394aaa90ee419a"}, - {file = "Pillow-8.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:620582db2a85b2df5f8a82ddeb52116560d7e5e6b055095f04ad828d1b0baa39"}, - {file = "Pillow-8.4.0-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:1bc723b434fbc4ab50bb68e11e93ce5fb69866ad621e3c2c9bdb0cd70e345f55"}, - {file = "Pillow-8.4.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72cbcfd54df6caf85cc35264c77ede902452d6df41166010262374155947460c"}, - {file = "Pillow-8.4.0-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70ad9e5c6cb9b8487280a02c0ad8a51581dcbbe8484ce058477692a27c151c0a"}, - {file = "Pillow-8.4.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25a49dc2e2f74e65efaa32b153527fc5ac98508d502fa46e74fa4fd678ed6645"}, - {file = "Pillow-8.4.0-cp36-cp36m-win32.whl", hash = "sha256:93ce9e955cc95959df98505e4608ad98281fff037350d8c2671c9aa86bcf10a9"}, - {file = "Pillow-8.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2e4440b8f00f504ee4b53fe30f4e381aae30b0568193be305256b1462216feff"}, - {file = "Pillow-8.4.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:8c803ac3c28bbc53763e6825746f05cc407b20e4a69d0122e526a582e3b5e153"}, - {file = "Pillow-8.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8a17b5d948f4ceeceb66384727dde11b240736fddeda54ca740b9b8b1556b29"}, - {file = "Pillow-8.4.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1394a6ad5abc838c5cd8a92c5a07535648cdf6d09e8e2d6df916dfa9ea86ead8"}, - {file = "Pillow-8.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:792e5c12376594bfcb986ebf3855aa4b7c225754e9a9521298e460e92fb4a488"}, - {file = "Pillow-8.4.0-cp37-cp37m-win32.whl", hash = "sha256:d99ec152570e4196772e7a8e4ba5320d2d27bf22fdf11743dd882936ed64305b"}, - {file = "Pillow-8.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:7b7017b61bbcdd7f6363aeceb881e23c46583739cb69a3ab39cb384f6ec82e5b"}, - {file = "Pillow-8.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:d89363f02658e253dbd171f7c3716a5d340a24ee82d38aab9183f7fdf0cdca49"}, - {file = "Pillow-8.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0a0956fdc5defc34462bb1c765ee88d933239f9a94bc37d132004775241a7585"}, - {file = "Pillow-8.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b7bb9de00197fb4261825c15551adf7605cf14a80badf1761d61e59da347779"}, - {file = "Pillow-8.4.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72b9e656e340447f827885b8d7a15fc8c4e68d410dc2297ef6787eec0f0ea409"}, - {file = "Pillow-8.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5a4532a12314149d8b4e4ad8ff09dde7427731fcfa5917ff16d0291f13609df"}, - {file = "Pillow-8.4.0-cp38-cp38-win32.whl", hash = "sha256:82aafa8d5eb68c8463b6e9baeb4f19043bb31fefc03eb7b216b51e6a9981ae09"}, - {file = "Pillow-8.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:066f3999cb3b070a95c3652712cffa1a748cd02d60ad7b4e485c3748a04d9d76"}, - {file = "Pillow-8.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:5503c86916d27c2e101b7f71c2ae2cddba01a2cf55b8395b0255fd33fa4d1f1a"}, - {file = "Pillow-8.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4acc0985ddf39d1bc969a9220b51d94ed51695d455c228d8ac29fcdb25810e6e"}, - {file = "Pillow-8.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b052a619a8bfcf26bd8b3f48f45283f9e977890263e4571f2393ed8898d331b"}, - {file = "Pillow-8.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:493cb4e415f44cd601fcec11c99836f707bb714ab03f5ed46ac25713baf0ff20"}, - {file = "Pillow-8.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8831cb7332eda5dc89b21a7bce7ef6ad305548820595033a4b03cf3091235ed"}, - {file = "Pillow-8.4.0-cp39-cp39-win32.whl", hash = "sha256:5e9ac5f66616b87d4da618a20ab0a38324dbe88d8a39b55be8964eb520021e02"}, - {file = "Pillow-8.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:3eb1ce5f65908556c2d8685a8f0a6e989d887ec4057326f6c22b24e8a172c66b"}, - {file = "Pillow-8.4.0-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:ddc4d832a0f0b4c52fff973a0d44b6c99839a9d016fe4e6a1cb8f3eea96479c2"}, - {file = "Pillow-8.4.0-pp36-pypy36_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a3e5ddc44c14042f0844b8cf7d2cd455f6cc80fd7f5eefbe657292cf601d9ad"}, - {file = "Pillow-8.4.0-pp36-pypy36_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c70e94281588ef053ae8998039610dbd71bc509e4acbc77ab59d7d2937b10698"}, - {file = "Pillow-8.4.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:3862b7256046fcd950618ed22d1d60b842e3a40a48236a5498746f21189afbbc"}, - {file = "Pillow-8.4.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4901622493f88b1a29bd30ec1a2f683782e57c3c16a2dbc7f2595ba01f639df"}, - {file = "Pillow-8.4.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84c471a734240653a0ec91dec0996696eea227eafe72a33bd06c92697728046b"}, - {file = "Pillow-8.4.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:244cf3b97802c34c41905d22810846802a3329ddcb93ccc432870243211c79fc"}, - {file = "Pillow-8.4.0.tar.gz", hash = "sha256:b8e2f83c56e141920c39464b852de3719dfbfb6e3c99a2d8da0edf4fb33176ed"}, + {file = "Pillow-9.0.0-cp310-cp310-macosx_10_10_universal2.whl", hash = "sha256:113723312215b25c22df1fdf0e2da7a3b9c357a7d24a93ebbe80bfda4f37a8d4"}, + {file = "Pillow-9.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bb47a548cea95b86494a26c89d153fd31122ed65255db5dcbc421a2d28eb3379"}, + {file = "Pillow-9.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31b265496e603985fad54d52d11970383e317d11e18e856971bdbb86af7242a4"}, + {file = "Pillow-9.0.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d154ed971a4cc04b93a6d5b47f37948d1f621f25de3e8fa0c26b2d44f24e3e8f"}, + {file = "Pillow-9.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80fe92813d208ce8aa7d76da878bdc84b90809f79ccbad2a288e9bcbeac1d9bd"}, + {file = "Pillow-9.0.0-cp310-cp310-win32.whl", hash = "sha256:d5dcea1387331c905405b09cdbfb34611050cc52c865d71f2362f354faee1e9f"}, + {file = "Pillow-9.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:52abae4c96b5da630a8b4247de5428f593465291e5b239f3f843a911a3cf0105"}, + {file = "Pillow-9.0.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:72c3110228944019e5f27232296c5923398496b28be42535e3b2dc7297b6e8b6"}, + {file = "Pillow-9.0.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97b6d21771da41497b81652d44191489296555b761684f82b7b544c49989110f"}, + {file = "Pillow-9.0.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72f649d93d4cc4d8cf79c91ebc25137c358718ad75f99e99e043325ea7d56100"}, + {file = "Pillow-9.0.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aaf07085c756f6cb1c692ee0d5a86c531703b6e8c9cae581b31b562c16b98ce"}, + {file = "Pillow-9.0.0-cp37-cp37m-win32.whl", hash = "sha256:03b27b197deb4ee400ed57d8d4e572d2d8d80f825b6634daf6e2c18c3c6ccfa6"}, + {file = "Pillow-9.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a09a9d4ec2b7887f7a088bbaacfd5c07160e746e3d47ec5e8050ae3b2a229e9f"}, + {file = "Pillow-9.0.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:490e52e99224858f154975db61c060686df8a6b3f0212a678e5d2e2ce24675c9"}, + {file = "Pillow-9.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:500d397ddf4bbf2ca42e198399ac13e7841956c72645513e8ddf243b31ad2128"}, + {file = "Pillow-9.0.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ebd8b9137630a7bbbff8c4b31e774ff05bbb90f7911d93ea2c9371e41039b52"}, + {file = "Pillow-9.0.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd0e5062f11cb3e730450a7d9f323f4051b532781026395c4323b8ad055523c4"}, + {file = "Pillow-9.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f3b4522148586d35e78313db4db0df4b759ddd7649ef70002b6c3767d0fdeb7"}, + {file = "Pillow-9.0.0-cp38-cp38-win32.whl", hash = "sha256:0b281fcadbb688607ea6ece7649c5d59d4bbd574e90db6cd030e9e85bde9fecc"}, + {file = "Pillow-9.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:b5050d681bcf5c9f2570b93bee5d3ec8ae4cf23158812f91ed57f7126df91762"}, + {file = "Pillow-9.0.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:c2067b3bb0781f14059b112c9da5a91c80a600a97915b4f48b37f197895dd925"}, + {file = "Pillow-9.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2d16b6196fb7a54aff6b5e3ecd00f7c0bab1b56eee39214b2b223a9d938c50af"}, + {file = "Pillow-9.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98cb63ca63cb61f594511c06218ab4394bf80388b3d66cd61d0b1f63ee0ea69f"}, + {file = "Pillow-9.0.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bc462d24500ba707e9cbdef436c16e5c8cbf29908278af053008d9f689f56dee"}, + {file = "Pillow-9.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3586e12d874ce2f1bc875a3ffba98732ebb12e18fb6d97be482bd62b56803281"}, + {file = "Pillow-9.0.0-cp39-cp39-win32.whl", hash = "sha256:68e06f8b2248f6dc8b899c3e7ecf02c9f413aab622f4d6190df53a78b93d97a5"}, + {file = "Pillow-9.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:6579f9ba84a3d4f1807c4aab4be06f373017fc65fff43498885ac50a9b47a553"}, + {file = "Pillow-9.0.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:47f5cf60bcb9fbc46011f75c9b45a8b5ad077ca352a78185bd3e7f1d294b98bb"}, + {file = "Pillow-9.0.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fd8053e1f8ff1844419842fd474fc359676b2e2a2b66b11cc59f4fa0a301315"}, + {file = "Pillow-9.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c5439bfb35a89cac50e81c751317faea647b9a3ec11c039900cd6915831064d"}, + {file = "Pillow-9.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:95545137fc56ce8c10de646074d242001a112a92de169986abd8c88c27566a05"}, + {file = "Pillow-9.0.0.tar.gz", hash = "sha256:ee6e2963e92762923956fe5d3479b1fdc3b76c83f290aad131a2f98c3df0593e"}, ] platformdirs = [ {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, @@ -2392,6 +2426,8 @@ pynput = [ ] pyobjc-core = [ {file = "pyobjc-core-7.3.tar.gz", hash = "sha256:5081aedf8bb40aac1a8ad95adac9e44e148a882686ded614adf46bb67fd67574"}, + {file = "pyobjc_core-7.3-1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a1f1e6b457127cbf2b5bd2b94520a7c89fb590b739911eadb2b0499a3a5b0e6f"}, + {file = "pyobjc_core-7.3-1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:ed708cc47bae8b711f81f252af09898a5f986c7a38cec5ad5623d571d328bff8"}, {file = "pyobjc_core-7.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e93ad769a20b908778fe950f62a843a6d8f0fa71996e5f3cc9fab5ae7d17771"}, {file = "pyobjc_core-7.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9f63fd37bbf3785af4ddb2f86cad5ca81c62cfc7d1c0099637ca18343c3656c1"}, {file = "pyobjc_core-7.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9b1311f72f2e170742a7ee3a8149f52c35158dc024a21e88d6f1e52ba5d718b"}, @@ -2410,6 +2446,8 @@ pyobjc-framework-applicationservices = [ ] pyobjc-framework-cocoa = [ {file = "pyobjc-framework-Cocoa-7.3.tar.gz", hash = "sha256:b18d05e7a795a3455ad191c3e43d6bfa673c2a4fd480bb1ccf57191051b80b7e"}, + {file = "pyobjc_framework_Cocoa-7.3-1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1e31376806e5de883a1d7c7c87d9ff2a8b09fc05d267e0dfce6e42409fb70c67"}, + {file = "pyobjc_framework_Cocoa-7.3-1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:d999387927284346035cb63ebb51f86331abc41f9376f9a6970e7f18207db392"}, {file = "pyobjc_framework_Cocoa-7.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9edffdfa6dd1f71f21b531c3e61fdd3e4d5d3bf6c5a528c98e88828cd60bac11"}, {file = "pyobjc_framework_Cocoa-7.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:35a6340437a4e0109a302150b7d1f6baf57004ccf74834f9e6062fcafe2fd8d7"}, {file = "pyobjc_framework_Cocoa-7.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7c3886f2608ab3ed02482f8b2ebf9f782b324c559e84b52cfd92dba8a1109872"}, @@ -2418,6 +2456,8 @@ pyobjc-framework-cocoa = [ ] pyobjc-framework-quartz = [ {file = "pyobjc-framework-Quartz-7.3.tar.gz", hash = "sha256:98812844c34262def980bdf60923a875cd43428a8375b6fd53bd2cd800eccf0b"}, + {file = "pyobjc_framework_Quartz-7.3-1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1139bc6874c0f8b58f0b8602015e0994198bc506a6bcec1071208de32b55ed26"}, + {file = "pyobjc_framework_Quartz-7.3-1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:d94a3ed7051266c52392ec07d3b5adbf28d4be83341a24df0d88639344dcd84f"}, {file = "pyobjc_framework_Quartz-7.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1ef18f5a16511ded65980bf4f5983ea5d35c88224dbad1b3112abd29c60413ea"}, {file = "pyobjc_framework_Quartz-7.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3b41eec8d4b10c7c7e011e2f9051367f5499ef315ba52dfbae573c3a2e05469c"}, {file = "pyobjc_framework_Quartz-7.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c65456ed045dfe1711d0298734e5a3ad670f8c770f7eb3b19979256c388bdd2"}, @@ -2538,8 +2578,8 @@ snowballstemmer = [ {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, ] speedcopy = [ - {file = "speedcopy-2.1.0-py3-none-any.whl", hash = "sha256:b80926ff900c0d7c8a0cb5f0a407258dde83976d923e3a449ac5417aa6608f63"}, - {file = "speedcopy-2.1.0.tar.gz", hash = "sha256:8bb1a6c735900b83901a7be84ba2175ed3887c13c6786f97dea48f2ea7d504c2"}, + {file = "speedcopy-2.1.2-py3-none-any.whl", hash = "sha256:91e271b84c00952812dbf669d360b2610fd8fa198670373e02acf2a04db89a4c"}, + {file = "speedcopy-2.1.2.tar.gz", hash = "sha256:1b2d779fadebd53a59384f7d286c40b2ef382e0d000fa53011699fcd3190d33f"}, ] sphinx = [ {file = "Sphinx-3.5.3-py3-none-any.whl", hash = "sha256:3f01732296465648da43dec8fb40dc451ba79eb3e2cc5c6d79005fd98197107d"}, diff --git a/pyproject.toml b/pyproject.toml index 04d48401ab..56d059e447 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.8.0-nightly.5" # OpenPype +version = "3.8.1" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" @@ -45,7 +45,7 @@ jsonschema = "^3.2.0" keyring = "^22.0.1" log4mongo = "^1.7" pathlib2= "^2.3.5" # deadline submit publish job only (single place, maybe not needed?) -Pillow = "^8.3" # only used for slates prototype +Pillow = "^9.0" # used in TVPaint and for slates pyblish-base = "^1.8.8" pynput = "^1.7.2" # idle manager in tray pymongo = "^3.11.2" @@ -97,6 +97,7 @@ toml = "^0.10.2" # for parsing pyproject.toml [[tool.poetry.source]] name = "openpype" url = "https://distribute.openpype.io/wheels/" +secondary = true [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/repos/avalon-core b/repos/avalon-core index ffe9e910f1..159d2f23e4 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit ffe9e910f1f382e222d457d8e4a8426c41ed43ae +Subproject commit 159d2f23e4c79c04dfac57b68d2ee6ac67adec1b diff --git a/tests/integration/README.md b/tests/integration/README.md index 0b6a1804ae..eef8141127 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -5,6 +5,9 @@ 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. +Uses env var `HEADLESS_PUBLISH` (set in test data zip files) to differentiate between regular publish +and "automated" one. + How to run ---------- - activate `{OPENPYPE_ROOT}/.venv` diff --git a/tools/build_dependencies.py b/tools/build_dependencies.py index 8eaf9d87e3..d3566dd289 100644 --- a/tools/build_dependencies.py +++ b/tools/build_dependencies.py @@ -21,7 +21,7 @@ speed. import os import sys import site -from distutils.util import get_platform +from sysconfig import get_platform import platform import subprocess from pathlib import Path diff --git a/tools/create_zip.sh b/tools/create_zip.sh index 85ee18a839..46393f78b1 100755 --- a/tools/create_zip.sh +++ b/tools/create_zip.sh @@ -130,8 +130,8 @@ main () { fi echo -e "${BIGreen}>>>${RST} Generating zip from current sources ..." - PYTHONPATH="$openpype_root:$PYTHONPATH" - OPENPYPE_ROOT="$openpype_root" + export PYTHONPATH="$openpype_root:$PYTHONPATH" + export OPENPYPE_ROOT="$openpype_root" "$POETRY_HOME/bin/poetry" run python3 "$openpype_root/tools/create_zip.py" "$@" } diff --git a/website/docs/admin_settings_system.md b/website/docs/admin_settings_system.md index 6057ed0830..78be9fb01e 100644 --- a/website/docs/admin_settings_system.md +++ b/website/docs/admin_settings_system.md @@ -11,6 +11,8 @@ import TabItem from '@theme/TabItem'; Settings applicable to the full studio. +![general_settings](assets/settings/settings_system_general.png) + **`Studio Name`** - Full name of the studio (can be used as variable on some places) **`Studio Code`** - Studio acronym or a short code (can be used as variable on some places) @@ -24,10 +26,27 @@ as a naive barier to prevent artists from accidental setting changes. **`Disk mapping`** - Platform dependent configuration for mapping of virtual disk(s) on an artist's OpenPype machines before OP starts up. Uses `subst` command, if configured volume character in `Destination` field already exists, no re-mapping is done for that character(volume). +### OpenPype deployment control **`Versions Repository`** - Location where automatic update mechanism searches for zip files with OpenPype update packages. To read more about preparing OpenPype for automatic updates go to [Admin Distribute docs](admin_distribute#2-openpype-codebase) -![general_settings](assets/settings/settings_system_general.png) +**`Production version`** - Define what is current production version. When value is not set then latest version available in versions repository is resolved as production version. + +**`Staging version`** - Define what is current staging version. When value is not set then latest staging version available in versions repository is resolved as staging version. + +For more information about Production and Staging go to [Distribute](admin_distribute#staging-vs-production). + +**Production version** and **Staging version** fields will define which version will be used in studio. Filling explicit version will force new OpenPype processes to use it. That gives more control over studio deployment especially when some workstations don't have access to version repository (e.g. remote users). It can be also used to downgrade studio version when newer version have production breaking bug. + +When fields are not filled the latest version in versions repository is used as studio version. That makes updating easier as it is not needed to modify settings but workstations without access to versions repository can't find out which OpenPype version should be used. + +If version repository is not set or is not accessible for workstation the latest available version on workstation is used or version inside build. + +**`Version check interval`** - OpenPype tray application check if currently used OpenPype version is up to date with production/staging version. It is possible to modify how often the validation is triggered in minutes. It is possible to set the interval to `0`. That will turn off version validations but it is not recommend. + +A dialog asking for restart is shown when OpenPype tray application detect that different version should be used. +![general_settings](assets/settings/settings_system_version_update.png) +![general_settings](assets/settings/settings_system_version_downgrade.png) ## Modules diff --git a/website/docs/artist_concepts.md b/website/docs/artist_concepts.md index 6046ba6214..4d195e9220 100644 --- a/website/docs/artist_concepts.md +++ b/website/docs/artist_concepts.md @@ -26,7 +26,7 @@ Each published variant can come out of the software in multiple representations. ### Family -Each published [subset][3b89d8e0] can have exactly one family assigned to it. Family determines the type of data that the subset holds. Family doesn't dictate the file type, but can enforce certain technical specifications. For example OpenPype default configuration expects `model` family to only contain geometry without any shaders or joins when it is published. +Each published [subset][3b89d8e0] can have exactly one family assigned to it. Family determines the type of data that the subset holds. Family doesn't dictate the file type, but can enforce certain technical specifications. For example OpenPype default configuration expects `model` family to only contain geometry without any shaders or joints when it is published. [3b89d8e0]: #subset "subset" @@ -40,7 +40,7 @@ General term for Software or Application supported by OpenPype and Avalon. These ### Tool -Small piece of software usually dedicated to a particular purpose. Most of OpenPype and Avalon tools have GUI, but some are command line only +Small piece of software usually dedicated to a particular purpose. Most of OpenPype and Avalon tools have GUI, but some are command line only. ### Publish @@ -50,4 +50,4 @@ Process of exporting data from your work scene to versioned, immutable file that ### Load Process of importing previously published subsets into your current scene, using any of the OpenPype tools. -Loading asset using proper tools will ensure that all your scene content stays version controlled and updatable at a later point +Loading asset using proper tools will ensure that all your scene content stays version controlled and updatable at a later point. diff --git a/website/docs/assets/settings/settings_system_general.png b/website/docs/assets/settings/settings_system_general.png index d04586205d..a2a684caea 100644 Binary files a/website/docs/assets/settings/settings_system_general.png and b/website/docs/assets/settings/settings_system_general.png differ diff --git a/website/docs/assets/settings/settings_system_version_downgrade.png b/website/docs/assets/settings/settings_system_version_downgrade.png new file mode 100644 index 0000000000..e3a5d7f499 Binary files /dev/null and b/website/docs/assets/settings/settings_system_version_downgrade.png differ diff --git a/website/docs/assets/settings/settings_system_version_update.png b/website/docs/assets/settings/settings_system_version_update.png new file mode 100644 index 0000000000..50adbab173 Binary files /dev/null and b/website/docs/assets/settings/settings_system_version_update.png differ