diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 87d904fc84..25f36ebc9a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,13 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.17.2 + - 3.17.2-nightly.4 + - 3.17.2-nightly.3 + - 3.17.2-nightly.2 + - 3.17.2-nightly.1 + - 3.17.1 + - 3.17.1-nightly.3 - 3.17.1-nightly.2 - 3.17.1-nightly.1 - 3.17.0 @@ -128,13 +135,6 @@ body: - 3.14.11-nightly.2 - 3.14.11-nightly.1 - 3.14.10 - - 3.14.10-nightly.9 - - 3.14.10-nightly.8 - - 3.14.10-nightly.7 - - 3.14.10-nightly.6 - - 3.14.10-nightly.5 - - 3.14.10-nightly.4 - - 3.14.10-nightly.3 validations: required: true - type: dropdown diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bcf66a210..7d5cf2c4d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,741 @@ # Changelog +## [3.17.2](https://github.com/ynput/OpenPype/tree/3.17.2) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.1...3.17.2) + +### **πŸ†• New features** + + +
+Maya: Add MayaPy application. #5705 + +This adds mayapy to the application to be launched from a task. + + +___ + +
+ + +
+Feature: Copy resources when downloading last workfile #4944 + +When the last published workfile is downloaded as a prelaunch hook, all resource files referenced in the workfile representation are copied to the `resources` folder, which is inside the local workfile folder. + + +___ + +
+ + +
+Blender: Deadline support #5438 + +Add Deadline support for Blender. + + +___ + +
+ + +
+Fusion: implement toggle to use Deadline plugin FusionCmd #5678 + +Fusion 17 doesn't work in DL 10.3, but FusionCmd does. It might be probably better option as headless variant.Fusion plugin seems to be closing and reopening application when worker is running on artist machine, not so with FusionCmdAdded configuration to Project Settings for admin to select appropriate Deadline plugin: + + +___ + +
+ + +
+Loader tool: Refactor loader tool (for AYON) #5729 + +Refactored loader tool to new tool. Separated backend and frontend logic. Refactored logic is AYON-centric and is used only in AYON mode, so it does not affect OpenPype. The tool is also replacing library loader. + + +___ + +
+ +### **πŸš€ Enhancements** + + +
+Maya: implement matchmove publishing #5445 + +Add possibility to export multiple cameras in single `matchmove` family instance, both in `abc` and `ma`.Exposed flag 'Keep image planes' to control export of image planes. + + +___ + +
+ + +
+Maya: Add optional Fbx extractors in Rig and Animation family #5589 + +This PR allows user to export control rigs(optionally with mesh) and animated rig in fbx optionally by attaching the rig objects to the two newly introduced sets. + + +___ + +
+ + +
+Maya: Optional Resolution Validator for Render #5693 + +Adding optional resolution validator for maya in render family, similar to the one in Max.It checks if the resolution in render setting aligns with that in setting from the db. + + +___ + +
+ + +
+Use host's node uniqueness for instance id in new publisher #5490 + +Instead of writing `instance_id` as parm or attributes on the publish instances we can, for some hosts, just rely on a unique name or path within the scene to refer to that particular instance. By doing so we fix #4820 because upon duplicating such a publish instance using the host's (DCC) functionality the uniqueness for the duplicate is then already ensured instead of attributes remaining exact same value as where to were duplicated from, making `instance_id` a non-unique value. + + +___ + +
+ + +
+Max: Implementation of OCIO configuration #5499 + +Resolve #5473 Implementation of OCIO configuration for Max 2024 regarding to the update of Max 2024 + + +___ + +
+ + +
+Nuke: Multiple format supports for ExtractReviewDataMov #5623 + +This PR would fix the bug of the plugin `ExtractReviewDataMov` not being able to support extensions other than `mov`. The plugin is also renamed to `ExtractReviewDataBakingStreams` as i provides multiple format supoort. + + +___ + +
+ + +
+Bugfix: houdini switching context doesnt update variables #5651 + +Allows admins to have a list of vars (e.g. JOB) with (dynamic) values that will be updated on context changes, e.g. when switching to another asset or task.Using template keys is supported but formatting keys capitalization variants is not, e.g. {Asset} and {ASSET} won't workDisabling Update Houdini vars on context change feature will leave all Houdini vars unmanaged and thus no context update changes will occur.Also, this PR adds a new button in menu to update vars on demand. + + +___ + +
+ + +
+Publisher: Fix report maker memory leak + optimize lookups using set #5667 + +Fixes a memory leak where resetting publisher does not clear the stored plugins for the Publish Report Maker.Also changes the stored plugins to a `set` to optimize the lookup speeds. + + +___ + +
+ + +
+Add openpype_mongo command flag for testing. #5676 + +Instead of changing the environment, this command flag allows for changing the database. + + +___ + +
+ + +
+Nuke: minor docstring and code tweaks for ExtractReviewMov #5695 + +Code and docstring tweaks on https://github.com/ynput/OpenPype/pull/5623 + + +___ + +
+ + +
+AYON: Small settings fixes #5699 + +Small changes/fixes related to AYON settings. All foundry apps variant `13-0` has label `13.0`. Key `"ExtractReviewIntermediates"` is not mandatory in settings. + + +___ + +
+ + +
+Blender: Alembic Animation loader #5711 + +Implemented loading Alembic Animations in Blender. + + +___ + +
+ +### **πŸ› Bug fixes** + + +
+Maya: Missing "data" field and enabling of audio #5618 + +When updating audio containers, the field "data" was missing and the audio node was not enabled on the timeline. + + +___ + +
+ + +
+Maya: Bug in validate Plug-in Path Attribute #5687 + +Overwriting list with string is causing `TypeError: string indices must be integers` in subsequent iterations, crashing the validator plugin. + + +___ + +
+ + +
+General: Avoid fallback if value is 0 for handle start/end #5652 + +There's a bug on the `pyblish_functions.get_time_data_from_instance_or_context` where if `handleStart` or `handleEnd` on the instance are set to value 0 it's falling back to grabbing the handles from the instance context. Instead, the logic should be that it only falls back to the `instance.context` if the key doesn't exist.This change was only affecting me on the `handleStart`/`handleEnd` and it's unlikely it could cause issues on `frameStart`, `frameEnd` or `fps` but regardless, the `get` logic is wrong. + + +___ + +
+ + +
+Fusion: added missing env vars to Deadline submission #5659 + +Environment variables discerning type of job was missing. Without this injection of environment variables won't start. + + +___ + +
+ + +
+Nuke: workfile version synchronization settings fixed #5662 + +Settings for synchronizing workfile version to published products is fixed. + + +___ + +
+ + +
+AYON Workfiles Tool: Open workfile changes context #5671 + +Change context when workfile is opened. + + +___ + +
+ + +
+Blender: Fix remove/update in new layout instance #5679 + +Fixes an error that occurs when removing or updating an asset in a new layout instance. + + +___ + +
+ + +
+AYON Launcher tool: Fix refresh btn #5685 + +Refresh button does propagate refreshed content properly. Folders and tasks are cached for 60 seconds instead of 10 seconds. Auto-refresh in launcher will refresh only actions and related data which is project and project settings. + + +___ + +
+ + +
+Deadline: handle all valid paths in RenderExecutable #5694 + +This commit enhances the path resolution mechanism in the RenderExecutable function of the Ayon plugin. Previously, the function only considered paths starting with a tilde (~), ignoring other valid paths listed in exe_list. This limitation led to an empty expanded_paths list when none of the paths in exe_list started with a tilde, causing the function to fail in finding the Ayon executable.With this fix, the RenderExecutable function now correctly processes and includes all valid paths from exe_list, improving its reliability and preventing unnecessary errors related to Ayon executable location. + + +___ + +
+ + +
+AYON Launcher tool: Fix skip last workfile boolean #5700 + +Skip last workfile boolean works as expected. + + +___ + +
+ + +
+Chore: Explore here action can work without task #5703 + +Explore here action does not crash when task is not selected, and change error message a little. + + +___ + +
+ + +
+Testing: Inject mongo_url argument earlier #5706 + +Fix for https://github.com/ynput/OpenPype/pull/5676The Mongo url is used earlier in the execution. + + +___ + +
+ + +
+Blender: Add support to auto-install PySide2 in blender 4 #5723 + +Change version regex to support blender 4 subfolder. + + +___ + +
+ + +
+Fix: Hardcoded main site and wrongly copied workfile #5733 + +Fixing these two issues: +- Hardcoded main site -> Replaced by `anatomy.fill_root`. +- Workfiles can sometimes be copied while they shouldn't. + + +___ + +
+ + +
+Bugfix: ServerDeleteOperation asset -> folder conversion typo #5735 + +Fix ServerDeleteOperation asset -> folder conversion typo + + +___ + +
+ + +
+Nuke: loaders are filtering correctly #5739 + +Variable name for filtering by extensions were not correct - it suppose to be plural. It is fixed now and filtering is working as suppose to. + + +___ + +
+ + +
+Nuke: failing multiple thumbnails integration #5741 + +This handles the situation when `ExtractReviewIntermediates` (previously `ExtractReviewDataMov`) has multiple outputs, including thumbnails that need to be integrated. Previously, integrating the thumbnail representation was causing an issue in the integration process. However, we have now resolved this issue by no longer integrating thumbnails as loadable representations.NOW default is that thumbnail representation are NOT integrated (eg. they will not show up in DB > couldn't be Loaded in Loader) and no `_thumb.jpg` will be left in `render` (most likely) publish folder.IF there would be need to override this behavior, please use `project_settings/global/publish/PreIntegrateThumbnails` + + +___ + +
+ + +
+AYON Settings: Fix global overrides #5745 + +The `output` dictionary that gets passed into `ayon_settings._convert_global_project_settings` gets replaced when converting the settings for `ExtractOIIOTranscode`. This results in `global` not being in the output dictionary and thus the defaults being used and not the project overrides. + + +___ + +
+ + +
+Chore: AYON query functions arguments #5752 + +Fixed how `archived` argument is handled in get subsets/assets function. + + +___ + +
+ +### **πŸ”€ Refactored code** + + +
+Publisher: Refactor Report Maker plugin data storage to be a dict by plugin.id #5668 + +Refactor Report Maker plugin data storage to be a dict by `plugin.id`Also fixes `_current_plugin_data` type on `__init__` + + +___ + +
+ + +
+Chore: Refactor Resolve into new style HostBase, IWorkfileHost, ILoadHost #5701 + +Refactor Resolve into new style HostBase, IWorkfileHost, ILoadHost + + +___ + +
+ +### **Merged pull requests** + + +
+Chore: Maya reduce get project settings calls #5669 + +Re-use system settings / project settings where we can instead of requerying. + + +___ + +
+ + +
+Extended error message when getting subset name #5649 + +Each Creator is using `get_subset_name` functions which collects context data and fills configured template with placeholders.If any key is missing in the template, non descriptive error is thrown.This should provide more verbose message: + + +___ + +
+ + +
+Tests: Remove checks for env var #5696 + +Env var will be filled in `env_var` fixture, here it is too early to check + + +___ + +
+ + + + +## [3.17.1](https://github.com/ynput/OpenPype/tree/3.17.1) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.0...3.17.1) + +### **πŸ†• New features** + + +
+Unreal: Yeti support #5643 + +Implemented Yeti support for Unreal. + + +___ + +
+ + +
+Houdini: Add Static Mesh product-type (family) #5481 + +This PR adds support to publish Unreal Static Mesh in Houdini as FBXQuick recap +- [x] Add UE Static Mesh Creator +- [x] Dynamic subset name like in Maya +- [x] Collect Static Mesh Type +- [x] Update collect output node +- [x] Validate FBX output node +- [x] Validate mesh is static +- [x] Validate Unreal Static Mesh Name +- [x] Validate Subset Name +- [x] FBX Extractor +- [x] FBX Loader +- [x] Update OP Settings +- [x] Update AYON Settings + + +___ + +
+ + +
+Launcher tool: Refactor launcher tool (for AYON) #5612 + +Refactored launcher tool to new tool. Separated backend and frontend logic. Refactored logic is AYON-centric and is used only in AYON mode, so it does not affect OpenPype. + + +___ + +
+ +### **πŸš€ Enhancements** + + +
+Maya: Use custom staging dir function for Maya renders - OP-5265 #5186 + +Check for custom staging dir when setting the renders output folder in Maya. + + +___ + +
+ + +
+Colorspace: updating file path detection methods #5273 + +Support for OCIO v2 file rules integrated into the available color management API + + +___ + +
+ + +
+Chore: add default isort config #5572 + +Add default configuration for isort tool + + +___ + +
+ + +
+Deadline: set PATH environment in deadline jobs by GlobalJobPreLoad #5622 + +This PR makes `GlobalJobPreLoad` to set `PATH` environment in deadline jobs so that we don't have to use the full executable path for deadline to launch the dcc app. This trick should save us adding logic to pass houdini patch version and modifying Houdini deadline plugin. This trick should work with other DCCs + + +___ + +
+ + +
+nuke: extract review data mov read node with expression #5635 + +Some productions might have set default values for read nodes, those settings are not colliding anymore now. + + +___ + +
+ +### **πŸ› Bug fixes** + + +
+Maya: Support new publisher for colorsets validation. #5630 + +Fix `validate_color_sets` for the new publisher.In current `develop` the repair option does not appear due to wrong error raising. + + +___ + +
+ + +
+Houdini: Camera Loader fix mismatch for Maya cameras #5584 + +This PR adds +- A workaround to match Maya render mask in Houdini +- `SetCameraResolution` inventory action +- set camera resolution when loading or updating camera + + +___ + +
+ + +
+Nuke: fix set colorspace on writes #5634 + +Colorspace is set correctly to any write node created from publisher. + + +___ + +
+ + +
+TVPaint: Fix review family extraction #5637 + +Extractor marks representation of review instance with review tag. + + +___ + +
+ + +
+AYON settings: Extract OIIO transcode settings #5639 + +Output definitions of Extract OIIO transcode have name to match OpenPype settings, and the settings are converted to dictionary in settings conversion. + + +___ + +
+ + +
+AYON: Fix task type short name conversion #5641 + +Convert AYON task type short name for OpenPype correctly. + + +___ + +
+ + +
+colorspace: missing `allowed_exts` fix #5646 + +Colorspace module is not failing due to missing `allowed_exts` attribute. + + +___ + +
+ + +
+Photoshop: remove trailing underscore in subset name #5647 + +If {layer} placeholder is at the end of subset name template and not used (for example in `auto_image` where separating it by layer doesn't make any sense) trailing '_' was kept. This updates cleaning logic and extracts it as it might be similar in regular `image` instance. + + +___ + +
+ + +
+traypublisher: missing `assetEntity` in context data #5648 + +Issue with missing `assetEnity` key in context data is not problem anymore. + + +___ + +
+ + +
+AYON: Workfiles tool save button works #5653 + +Fix save as button in workfiles tool.(It is mystery why this stopped to work??) + + +___ + +
+ + +
+Max: bug fix delete items from container #5658 + +Fix the bug shown when clicking "Delete Items from Container" and selecting nothing and press ok. + + +___ + +
+ +### **πŸ”€ Refactored code** + + +
+Chore: Remove unused functions from Fusion integration #5617 + +Cleanup unused code from Fusion integration + + +___ + +
+ +### **Merged pull requests** + + +
+Increase timout for deadline test #5654 + +DL picks up jobs quite slow, so bump up delay. + + +___ + +
+ + + + ## [3.17.0](https://github.com/ynput/OpenPype/tree/3.17.0) diff --git a/openpype/cli.py b/openpype/cli.py index 0df277fb0a..7422f32f13 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -290,11 +290,15 @@ def run(script): "--setup_only", help="Only create dbs, do not run tests", default=None) +@click.option("--mongo_url", + help="MongoDB for testing.", + default=None) def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant, - timeout, setup_only): + timeout, setup_only, mongo_url): """Run all automatic tests after proper initialization via start.py""" PypeCommands().run_tests(folder, mark, pyargs, test_data_folder, - persist, app_variant, timeout, setup_only) + persist, app_variant, timeout, setup_only, + mongo_url) @main.command(help="DEPRECATED - run sync server") diff --git a/openpype/client/server/entities.py b/openpype/client/server/entities.py index 3ee62a3172..16223d3d91 100644 --- a/openpype/client/server/entities.py +++ b/openpype/client/server/entities.py @@ -75,9 +75,9 @@ def _get_subsets( ): fields.add(key) - active = None + active = True if archived: - active = False + active = None for subset in con.get_products( project_name, @@ -196,7 +196,7 @@ def get_assets( active = True if archived: - active = False + active = None con = get_server_api_connection() fields = folder_fields_v3_to_v4(fields, con) diff --git a/openpype/client/server/operations.py b/openpype/client/server/operations.py index eeb55784e1..5b38405c34 100644 --- a/openpype/client/server/operations.py +++ b/openpype/client/server/operations.py @@ -422,7 +422,7 @@ def failed_json_default(value): class ServerCreateOperation(CreateOperation): - """Opeartion to create an entity. + """Operation to create an entity. Args: project_name (str): On which project operation will happen. @@ -634,7 +634,7 @@ class ServerUpdateOperation(UpdateOperation): class ServerDeleteOperation(DeleteOperation): - """Opeartion to delete an entity. + """Operation to delete an entity. Args: project_name (str): On which project operation will happen. @@ -647,7 +647,7 @@ class ServerDeleteOperation(DeleteOperation): self._session = session if entity_type == "asset": - entity_type == "folder" + entity_type = "folder" elif entity_type == "hero_version": entity_type = "version" diff --git a/openpype/hooks/pre_foundry_apps.py b/openpype/hooks/pre_new_console_apps.py similarity index 82% rename from openpype/hooks/pre_foundry_apps.py rename to openpype/hooks/pre_new_console_apps.py index 7536df4c16..9727b4fb78 100644 --- a/openpype/hooks/pre_foundry_apps.py +++ b/openpype/hooks/pre_new_console_apps.py @@ -2,7 +2,7 @@ import subprocess from openpype.lib.applications import PreLaunchHook, LaunchTypes -class LaunchFoundryAppsWindows(PreLaunchHook): +class LaunchNewConsoleApps(PreLaunchHook): """Foundry applications have specific way how to launch them. Nuke is executed "like" python process so it is required to pass @@ -13,13 +13,15 @@ class LaunchFoundryAppsWindows(PreLaunchHook): # Should be as last hook because must change launch arguments to string order = 1000 - app_groups = {"nuke", "nukeassist", "nukex", "hiero", "nukestudio"} + app_groups = { + "nuke", "nukeassist", "nukex", "hiero", "nukestudio", "mayapy" + } platforms = {"windows"} launch_types = {LaunchTypes.local} def execute(self): # Change `creationflags` to CREATE_NEW_CONSOLE - # - on Windows nuke will create new window using its console + # - on Windows some apps will create new window using its console # Set `stdout` and `stderr` to None so new created console does not # have redirected output to DEVNULL in build self.launch_context.kwargs.update({ diff --git a/openpype/hooks/pre_ocio_hook.py b/openpype/hooks/pre_ocio_hook.py index add3a0adaf..e695cf3fe8 100644 --- a/openpype/hooks/pre_ocio_hook.py +++ b/openpype/hooks/pre_ocio_hook.py @@ -13,7 +13,7 @@ class OCIOEnvHook(PreLaunchHook): "fusion", "blender", "aftereffects", - "max", + "3dsmax", "houdini", "maya", "nuke", diff --git a/openpype/hosts/blender/api/__init__.py b/openpype/hosts/blender/api/__init__.py index 75a11affde..e15f1193a5 100644 --- a/openpype/hosts/blender/api/__init__.py +++ b/openpype/hosts/blender/api/__init__.py @@ -38,6 +38,8 @@ from .lib import ( from .capture import capture +from .render_lib import prepare_rendering + __all__ = [ "install", @@ -66,4 +68,5 @@ __all__ = [ "get_selection", "capture", # "unique_name", + "prepare_rendering", ] diff --git a/openpype/hosts/blender/api/colorspace.py b/openpype/hosts/blender/api/colorspace.py new file mode 100644 index 0000000000..4521612b7d --- /dev/null +++ b/openpype/hosts/blender/api/colorspace.py @@ -0,0 +1,51 @@ +import attr + +import bpy + + +@attr.s +class LayerMetadata(object): + """Data class for Render Layer metadata.""" + frameStart = attr.ib() + frameEnd = attr.ib() + + +@attr.s +class RenderProduct(object): + """ + Getting Colorspace as Specific Render Product Parameter for submitting + publish job. + """ + colorspace = attr.ib() # colorspace + view = attr.ib() # OCIO view transform + productName = attr.ib(default=None) + + +class ARenderProduct(object): + def __init__(self): + """Constructor.""" + # Initialize + self.layer_data = self._get_layer_data() + self.layer_data.products = self.get_render_products() + + def _get_layer_data(self): + scene = bpy.context.scene + + return LayerMetadata( + frameStart=int(scene.frame_start), + frameEnd=int(scene.frame_end), + ) + + def get_render_products(self): + """To be implemented by renderer class. + This should return a list of RenderProducts. + Returns: + list: List of RenderProduct + """ + return [ + RenderProduct( + colorspace="sRGB", + view="ACES 1.0", + productName="" + ) + ] diff --git a/openpype/hosts/blender/api/ops.py b/openpype/hosts/blender/api/ops.py index 62d7987b47..0eb90eeff9 100644 --- a/openpype/hosts/blender/api/ops.py +++ b/openpype/hosts/blender/api/ops.py @@ -16,6 +16,7 @@ import bpy import bpy.utils.previews from openpype import style +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import get_current_asset_name, get_current_task_name from openpype.tools.utils import host_tools @@ -331,10 +332,11 @@ class LaunchWorkFiles(LaunchQtApp): def execute(self, context): result = super().execute(context) - self._window.set_context({ - "asset": get_current_asset_name(), - "task": get_current_task_name() - }) + if not AYON_SERVER_ENABLED: + self._window.set_context({ + "asset": get_current_asset_name(), + "task": get_current_task_name() + }) return result def before_window_show(self): diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 29339a512c..84af0904f0 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -460,36 +460,6 @@ def ls() -> Iterator: 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.""" diff --git a/openpype/hosts/blender/api/render_lib.py b/openpype/hosts/blender/api/render_lib.py new file mode 100644 index 0000000000..d564b5ebcb --- /dev/null +++ b/openpype/hosts/blender/api/render_lib.py @@ -0,0 +1,255 @@ +import os + +import bpy + +from openpype.settings import get_project_settings +from openpype.pipeline import get_current_project_name + + +def get_default_render_folder(settings): + """Get default render folder from blender settings.""" + + return (settings["blender"] + ["RenderSettings"] + ["default_render_image_folder"]) + + +def get_aov_separator(settings): + """Get aov separator from blender settings.""" + + aov_sep = (settings["blender"] + ["RenderSettings"] + ["aov_separator"]) + + if aov_sep == "dash": + return "-" + elif aov_sep == "underscore": + return "_" + elif aov_sep == "dot": + return "." + else: + raise ValueError(f"Invalid aov separator: {aov_sep}") + + +def get_image_format(settings): + """Get image format from blender settings.""" + + return (settings["blender"] + ["RenderSettings"] + ["image_format"]) + + +def get_multilayer(settings): + """Get multilayer from blender settings.""" + + return (settings["blender"] + ["RenderSettings"] + ["multilayer_exr"]) + + +def get_render_product(output_path, name, aov_sep): + """ + Generate the path to the render product. Blender interprets the `#` + as the frame number, when it renders. + + Args: + file_path (str): The path to the blender scene. + render_folder (str): The render folder set in settings. + file_name (str): The name of the blender scene. + instance (pyblish.api.Instance): The instance to publish. + ext (str): The image format to render. + """ + filepath = os.path.join(output_path, name) + render_product = f"{filepath}{aov_sep}beauty.####" + render_product = render_product.replace("\\", "/") + + return render_product + + +def set_render_format(ext, multilayer): + # Set Blender to save the file with the right extension + bpy.context.scene.render.use_file_extension = True + + image_settings = bpy.context.scene.render.image_settings + + if ext == "exr": + image_settings.file_format = ( + "OPEN_EXR_MULTILAYER" if multilayer else "OPEN_EXR") + elif ext == "bmp": + image_settings.file_format = "BMP" + elif ext == "rgb": + image_settings.file_format = "IRIS" + elif ext == "png": + image_settings.file_format = "PNG" + elif ext == "jpeg": + image_settings.file_format = "JPEG" + elif ext == "jp2": + image_settings.file_format = "JPEG2000" + elif ext == "tga": + image_settings.file_format = "TARGA" + elif ext == "tif": + image_settings.file_format = "TIFF" + + +def set_render_passes(settings): + aov_list = (settings["blender"] + ["RenderSettings"] + ["aov_list"]) + + custom_passes = (settings["blender"] + ["RenderSettings"] + ["custom_passes"]) + + vl = bpy.context.view_layer + + vl.use_pass_combined = "combined" in aov_list + vl.use_pass_z = "z" in aov_list + vl.use_pass_mist = "mist" in aov_list + vl.use_pass_normal = "normal" in aov_list + vl.use_pass_diffuse_direct = "diffuse_light" in aov_list + vl.use_pass_diffuse_color = "diffuse_color" in aov_list + vl.use_pass_glossy_direct = "specular_light" in aov_list + vl.use_pass_glossy_color = "specular_color" in aov_list + vl.eevee.use_pass_volume_direct = "volume_light" in aov_list + vl.use_pass_emit = "emission" in aov_list + vl.use_pass_environment = "environment" in aov_list + vl.use_pass_shadow = "shadow" in aov_list + vl.use_pass_ambient_occlusion = "ao" in aov_list + + cycles = vl.cycles + + cycles.denoising_store_passes = "denoising" in aov_list + cycles.use_pass_volume_direct = "volume_direct" in aov_list + cycles.use_pass_volume_indirect = "volume_indirect" in aov_list + + aovs_names = [aov.name for aov in vl.aovs] + for cp in custom_passes: + cp_name = cp[0] + if cp_name not in aovs_names: + aov = vl.aovs.add() + aov.name = cp_name + else: + aov = vl.aovs[cp_name] + aov.type = cp[1].get("type", "VALUE") + + return aov_list, custom_passes + + +def set_node_tree(output_path, name, aov_sep, ext, multilayer): + # Set the scene to use the compositor node tree to render + bpy.context.scene.use_nodes = True + + tree = bpy.context.scene.node_tree + + # Get the Render Layers node + rl_node = None + for node in tree.nodes: + if node.bl_idname == "CompositorNodeRLayers": + rl_node = node + break + + # If there's not a Render Layers node, we create it + if not rl_node: + rl_node = tree.nodes.new("CompositorNodeRLayers") + + # Get the enabled output sockets, that are the active passes for the + # render. + # We also exclude some layers. + exclude_sockets = ["Image", "Alpha", "Noisy Image"] + passes = [ + socket + for socket in rl_node.outputs + if socket.enabled and socket.name not in exclude_sockets + ] + + # Remove all output nodes + for node in tree.nodes: + if node.bl_idname == "CompositorNodeOutputFile": + tree.nodes.remove(node) + + # Create a new output node + output = tree.nodes.new("CompositorNodeOutputFile") + + image_settings = bpy.context.scene.render.image_settings + output.format.file_format = image_settings.file_format + + # In case of a multilayer exr, we don't need to use the output node, + # because the blender render already outputs a multilayer exr. + if ext == "exr" and multilayer: + output.layer_slots.clear() + return [] + + output.file_slots.clear() + output.base_path = output_path + + aov_file_products = [] + + # For each active render pass, we add a new socket to the output node + # and link it + for render_pass in passes: + filepath = f"{name}{aov_sep}{render_pass.name}.####" + + output.file_slots.new(filepath) + + aov_file_products.append( + (render_pass.name, os.path.join(output_path, filepath))) + + node_input = output.inputs[-1] + + tree.links.new(render_pass, node_input) + + return aov_file_products + + +def imprint_render_settings(node, data): + RENDER_DATA = "render_data" + if not node.get(RENDER_DATA): + node[RENDER_DATA] = {} + for key, value in data.items(): + if value is None: + continue + node[RENDER_DATA][key] = value + + +def prepare_rendering(asset_group): + name = asset_group.name + + filepath = bpy.data.filepath + assert filepath, "Workfile not saved. Please save the file first." + + file_path = os.path.dirname(filepath) + file_name = os.path.basename(filepath) + file_name, _ = os.path.splitext(file_name) + + project = get_current_project_name() + settings = get_project_settings(project) + + render_folder = get_default_render_folder(settings) + aov_sep = get_aov_separator(settings) + ext = get_image_format(settings) + multilayer = get_multilayer(settings) + + set_render_format(ext, multilayer) + aov_list, custom_passes = set_render_passes(settings) + + output_path = os.path.join(file_path, render_folder, file_name) + + render_product = get_render_product(output_path, name, aov_sep) + aov_file_product = set_node_tree( + output_path, name, aov_sep, ext, multilayer) + + bpy.context.scene.render.filepath = render_product + + render_settings = { + "render_folder": render_folder, + "aov_separator": aov_sep, + "image_format": ext, + "multilayer_exr": multilayer, + "aov_list": aov_list, + "custom_passes": custom_passes, + "render_product": render_product, + "aov_file_product": aov_file_product, + "review": True, + } + + imprint_render_settings(asset_group, render_settings) diff --git a/openpype/hosts/blender/hooks/pre_pyside_install.py b/openpype/hosts/blender/hooks/pre_pyside_install.py index 777e383215..2aa3a5e49a 100644 --- a/openpype/hosts/blender/hooks/pre_pyside_install.py +++ b/openpype/hosts/blender/hooks/pre_pyside_install.py @@ -31,7 +31,7 @@ class InstallPySideToBlender(PreLaunchHook): def inner_execute(self): # Get blender's python directory - version_regex = re.compile(r"^[2-3]\.[0-9]+$") + version_regex = re.compile(r"^[2-4]\.[0-9]+$") platform = system().lower() executable = self.launch_context.executable.executable_path diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py new file mode 100644 index 0000000000..f938a21808 --- /dev/null +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -0,0 +1,53 @@ +"""Create render.""" +import bpy + +from openpype.pipeline import get_current_task_name +from openpype.hosts.blender.api import plugin, lib +from openpype.hosts.blender.api.render_lib import prepare_rendering +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES + + +class CreateRenderlayer(plugin.Creator): + """Single baked camera""" + + name = "renderingMain" + label = "Render" + family = "render" + icon = "eye" + + def process(self): + # Get Instance Container or create it if it does not exist + instances = bpy.data.collections.get(AVALON_INSTANCES) + if not instances: + instances = bpy.data.collections.new(name=AVALON_INSTANCES) + bpy.context.scene.collection.children.link(instances) + + # Create instance object + asset = self.data["asset"] + subset = self.data["subset"] + name = plugin.asset_name(asset, subset) + asset_group = bpy.data.collections.new(name=name) + + try: + instances.children.link(asset_group) + self.data['task'] = get_current_task_name() + lib.imprint(asset_group, self.data) + + prepare_rendering(asset_group) + except Exception: + # Remove the instance if there was an error + bpy.data.collections.remove(asset_group) + raise + + # TODO: this is undesiderable, but it's the only way to be sure that + # the file is saved before the render starts. + # Blender, by design, doesn't set the file as dirty if modifications + # happen by script. So, when creating the instance and setting the + # render settings, the file is not marked as dirty. This means that + # there is the risk of sending to deadline a file without the right + # settings. Even the validator to check that the file is saved will + # detect the file as saved, even if it isn't. The only solution for + # now it is to force the file to be saved. + bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath) + + return asset_group diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 292925c833..9b3d940536 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -26,8 +26,7 @@ class CacheModelLoader(plugin.AssetLoader): Note: At least for now it only supports Alembic files. """ - - families = ["model", "pointcache"] + families = ["model", "pointcache", "animation"] representations = ["abc"] label = "Load Alembic" @@ -53,16 +52,12 @@ class CacheModelLoader(plugin.AssetLoader): def _process(self, libpath, asset_group, group_name): plugin.deselect_all() - collection = bpy.context.view_layer.active_layer_collection.collection - relative = bpy.context.preferences.filepaths.use_relative_paths bpy.ops.wm.alembic_import( filepath=libpath, relative_path=relative ) - parent = bpy.context.scene.collection - imported = lib.get_selection() # Children must be linked before parents, @@ -79,6 +74,10 @@ class CacheModelLoader(plugin.AssetLoader): objects.reverse() for obj in objects: + # Unlink the object from all collections + collections = obj.users_collection + for collection in collections: + collection.objects.unlink(obj) name = obj.name obj.name = f"{group_name}:{name}" if obj.type != 'EMPTY': @@ -90,7 +89,7 @@ class CacheModelLoader(plugin.AssetLoader): material_slot.material.name = f"{group_name}:{name_mat}" if not obj.get(AVALON_PROPERTY): - obj[AVALON_PROPERTY] = dict() + obj[AVALON_PROPERTY] = {} avalon_info = obj[AVALON_PROPERTY] avalon_info.update({"container_name": group_name}) @@ -99,6 +98,18 @@ class CacheModelLoader(plugin.AssetLoader): return objects + def _link_objects(self, objects, collection, containers, asset_group): + # Link the imported objects to any collection where the asset group is + # linked to, except the AVALON_CONTAINERS collection + group_collections = [ + collection + for collection in asset_group.users_collection + if collection != containers] + + for obj in objects: + for collection in group_collections: + collection.objects.link(obj) + def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None @@ -120,18 +131,21 @@ class CacheModelLoader(plugin.AssetLoader): group_name = plugin.asset_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" - avalon_containers = bpy.data.collections.get(AVALON_CONTAINERS) - if not avalon_containers: - avalon_containers = bpy.data.collections.new( - name=AVALON_CONTAINERS) - bpy.context.scene.collection.children.link(avalon_containers) + containers = bpy.data.collections.get(AVALON_CONTAINERS) + if not containers: + containers = bpy.data.collections.new(name=AVALON_CONTAINERS) + bpy.context.scene.collection.children.link(containers) asset_group = bpy.data.objects.new(group_name, object_data=None) - avalon_containers.objects.link(asset_group) + containers.objects.link(asset_group) objects = self._process(libpath, asset_group, group_name) - bpy.context.scene.collection.objects.link(asset_group) + # Link the asset group to the active collection + collection = bpy.context.view_layer.active_layer_collection.collection + collection.objects.link(asset_group) + + self._link_objects(objects, asset_group, containers, asset_group) asset_group[AVALON_PROPERTY] = { "schema": "openpype:container-2.0", @@ -207,7 +221,11 @@ class CacheModelLoader(plugin.AssetLoader): mat = asset_group.matrix_basis.copy() self._remove(asset_group) - self._process(str(libpath), asset_group, object_name) + objects = self._process(str(libpath), asset_group, object_name) + + containers = bpy.data.collections.get(AVALON_CONTAINERS) + self._link_objects(objects, asset_group, containers, asset_group) + asset_group.matrix_basis = mat metadata["libpath"] = str(libpath) diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index fa41f4374b..25d6568889 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -244,7 +244,7 @@ class BlendLoader(plugin.AssetLoader): for parent in parent_containers: parent.get(AVALON_PROPERTY)["members"] = list(filter( lambda i: i not in members, - parent.get(AVALON_PROPERTY)["members"])) + parent.get(AVALON_PROPERTY).get("members", []))) for attr in attrs: for data in getattr(bpy.data, attr): diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py new file mode 100644 index 0000000000..92e2473a95 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +"""Collect render data.""" + +import os +import re + +import bpy + +from openpype.hosts.blender.api import colorspace +import pyblish.api + + +class CollectBlenderRender(pyblish.api.InstancePlugin): + """Gather all publishable render layers from renderSetup.""" + + order = pyblish.api.CollectorOrder + 0.01 + hosts = ["blender"] + families = ["render"] + label = "Collect Render Layers" + sync_workfile_version = False + + @staticmethod + def generate_expected_beauty( + render_product, frame_start, frame_end, frame_step, ext + ): + """ + Generate the expected files for the render product for the beauty + render. This returns a list of files that should be rendered. It + replaces the sequence of `#` with the frame number. + """ + path = os.path.dirname(render_product) + file = os.path.basename(render_product) + + expected_files = [] + + for frame in range(frame_start, frame_end + 1, frame_step): + frame_str = str(frame).rjust(4, "0") + filename = re.sub("#+", frame_str, file) + expected_file = f"{os.path.join(path, filename)}.{ext}" + expected_files.append(expected_file.replace("\\", "/")) + + return { + "beauty": expected_files + } + + @staticmethod + def generate_expected_aovs( + aov_file_product, frame_start, frame_end, frame_step, ext + ): + """ + Generate the expected files for the render product for the beauty + render. This returns a list of files that should be rendered. It + replaces the sequence of `#` with the frame number. + """ + expected_files = {} + + for aov_name, aov_file in aov_file_product: + path = os.path.dirname(aov_file) + file = os.path.basename(aov_file) + + aov_files = [] + + for frame in range(frame_start, frame_end + 1, frame_step): + frame_str = str(frame).rjust(4, "0") + filename = re.sub("#+", frame_str, file) + expected_file = f"{os.path.join(path, filename)}.{ext}" + aov_files.append(expected_file.replace("\\", "/")) + + expected_files[aov_name] = aov_files + + return expected_files + + def process(self, instance): + context = instance.context + + render_data = bpy.data.collections[str(instance)].get("render_data") + + assert render_data, "No render data found." + + self.log.info(f"render_data: {dict(render_data)}") + + render_product = render_data.get("render_product") + aov_file_product = render_data.get("aov_file_product") + ext = render_data.get("image_format") + multilayer = render_data.get("multilayer_exr") + + frame_start = context.data["frameStart"] + frame_end = context.data["frameEnd"] + frame_handle_start = context.data["frameStartHandle"] + frame_handle_end = context.data["frameEndHandle"] + + expected_beauty = self.generate_expected_beauty( + render_product, int(frame_start), int(frame_end), + int(bpy.context.scene.frame_step), ext) + + expected_aovs = self.generate_expected_aovs( + aov_file_product, int(frame_start), int(frame_end), + int(bpy.context.scene.frame_step), ext) + + expected_files = expected_beauty | expected_aovs + + instance.data.update({ + "family": "render.farm", + "frameStart": frame_start, + "frameEnd": frame_end, + "frameStartHandle": frame_handle_start, + "frameEndHandle": frame_handle_end, + "fps": context.data["fps"], + "byFrameStep": bpy.context.scene.frame_step, + "review": render_data.get("review", False), + "multipartExr": ext == "exr" and multilayer, + "farm": True, + "expectedFiles": [expected_files], + # OCIO not currently implemented in Blender, but the following + # settings are required by the schema, so it is hardcoded. + # TODO: Implement OCIO in Blender + "colorspaceConfig": "", + "colorspaceDisplay": "sRGB", + "colorspaceView": "ACES 1.0 SDR-video", + "renderProducts": colorspace.ARenderProduct(), + }) + + self.log.info(f"data: {instance.data}") diff --git a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py index 27fa4baf28..3d176f9c30 100644 --- a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py +++ b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py @@ -9,7 +9,8 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin): label = "Increment Workfile Version" optional = True hosts = ["blender"] - families = ["animation", "model", "rig", "action", "layout", "blendScene"] + families = ["animation", "model", "rig", "action", "layout", "blendScene", + "render"] def process(self, context): diff --git a/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py b/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py new file mode 100644 index 0000000000..14220b5c9c --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py @@ -0,0 +1,47 @@ +import os + +import bpy + +import pyblish.api +from openpype.pipeline.publish import ( + RepairAction, + ValidateContentsOrder, + PublishValidationError, + OptionalPyblishPluginMixin +) +from openpype.hosts.blender.api.render_lib import prepare_rendering + + +class ValidateDeadlinePublish(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validates Render File Directory is + not the same in every submission + """ + + order = ValidateContentsOrder + families = ["render.farm"] + hosts = ["blender"] + label = "Validate Render Output for Deadline" + optional = True + actions = [RepairAction] + + def process(self, instance): + if not self.is_active(instance.data): + return + filepath = bpy.data.filepath + file = os.path.basename(filepath) + filename, ext = os.path.splitext(file) + if filename not in bpy.context.scene.render.filepath: + raise PublishValidationError( + "Render output folder " + "doesn't match the blender scene name! " + "Use Repair action to " + "fix the folder file path.." + ) + + @classmethod + def repair(cls, instance): + container = bpy.data.collections[str(instance)] + prepare_rendering(container) + bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath) + cls.log.debug("Reset the render output folder...") diff --git a/openpype/hosts/blender/plugins/publish/validate_file_saved.py b/openpype/hosts/blender/plugins/publish/validate_file_saved.py new file mode 100644 index 0000000000..e191585c55 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/validate_file_saved.py @@ -0,0 +1,20 @@ +import bpy + +import pyblish.api + + +class ValidateFileSaved(pyblish.api.InstancePlugin): + """Validate that the workfile has been saved.""" + + order = pyblish.api.ValidatorOrder - 0.01 + hosts = ["blender"] + label = "Validate File Saved" + optional = False + exclude_families = [] + + def process(self, instance): + if [ef for ef in self.exclude_families + if instance.data["family"] in ef]: + return + if bpy.data.is_dirty: + raise RuntimeError("Workfile is not saved.") diff --git a/openpype/hosts/blender/plugins/publish/validate_render_camera_is_set.py b/openpype/hosts/blender/plugins/publish/validate_render_camera_is_set.py new file mode 100644 index 0000000000..ba3a796f35 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/validate_render_camera_is_set.py @@ -0,0 +1,17 @@ +import bpy + +import pyblish.api + + +class ValidateRenderCameraIsSet(pyblish.api.InstancePlugin): + """Validate that there is a camera set as active for rendering.""" + + order = pyblish.api.ValidatorOrder + hosts = ["blender"] + families = ["render"] + label = "Validate Render Camera Is Set" + optional = False + + def process(self, instance): + if not bpy.context.scene.camera: + raise RuntimeError("No camera is active for rendering.") diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index 39edca4de3..fccd8b2965 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -123,6 +123,9 @@ class CreateSaver(NewCreator): def _imprint(self, tool, data): # Save all data in a "openpype.{key}" = value data + # Instance id is the tool's name so we don't need to imprint as data + data.pop("instance_id", None) + active = data.pop("active", None) if active is not None: # Use active value to set the passthrough state @@ -188,6 +191,10 @@ class CreateSaver(NewCreator): passthrough = attrs["TOOLB_PassThrough"] data["active"] = not passthrough + # Override publisher's UUID generation because tool names are + # already unique in Fusion in a comp + data["instance_id"] = tool.Name + return data def get_pre_create_attr_defs(self): diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index a3f691e1fc..3db18ca69a 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import sys import os +import errno import re import uuid import logging @@ -9,10 +10,15 @@ import json import six +from openpype.lib import StringTemplate from openpype.client import get_asset_by_name +from openpype.settings import get_current_project_settings from openpype.pipeline import get_current_project_name, get_current_asset_name -from openpype.pipeline.context_tools import get_current_project_asset - +from openpype.pipeline.context_tools import ( + get_current_context_template_data, + get_current_project_asset +) +from openpype.widgets import popup import hou @@ -160,8 +166,6 @@ def validate_fps(): if current_fps != fps: - from openpype.widgets import popup - # Find main window parent = hou.ui.mainQtWindow() if parent is None: @@ -747,3 +751,99 @@ def get_camera_from_container(container): assert len(cameras) == 1, "Camera instance must have only one camera" return cameras[0] + + +def get_context_var_changes(): + """get context var changes.""" + + houdini_vars_to_update = {} + + project_settings = get_current_project_settings() + houdini_vars_settings = \ + project_settings["houdini"]["general"]["update_houdini_var_context"] + + if not houdini_vars_settings["enabled"]: + return houdini_vars_to_update + + houdini_vars = houdini_vars_settings["houdini_vars"] + + # No vars specified - nothing to do + if not houdini_vars: + return houdini_vars_to_update + + # Get Template data + template_data = get_current_context_template_data() + + # Set Houdini Vars + for item in houdini_vars: + # For consistency reasons we always force all vars to be uppercase + # Also remove any leading, and trailing whitespaces. + var = item["var"].strip().upper() + + # get and resolve template in value + item_value = StringTemplate.format_template( + item["value"], + template_data + ) + + if var == "JOB" and item_value == "": + # sync $JOB to $HIP if $JOB is empty + item_value = os.environ["HIP"] + + if item["is_directory"]: + item_value = item_value.replace("\\", "/") + + current_value = hou.hscript("echo -n `${}`".format(var))[0] + + if current_value != item_value: + houdini_vars_to_update[var] = ( + current_value, item_value, item["is_directory"] + ) + + return houdini_vars_to_update + + +def update_houdini_vars_context(): + """Update asset context variables""" + + for var, (_old, new, is_directory) in get_context_var_changes().items(): + if is_directory: + try: + os.makedirs(new) + except OSError as e: + if e.errno != errno.EEXIST: + print( + "Failed to create ${} dir. Maybe due to " + "insufficient permissions.".format(var) + ) + + hou.hscript("set {}={}".format(var, new)) + os.environ[var] = new + print("Updated ${} to {}".format(var, new)) + + +def update_houdini_vars_context_dialog(): + """Show pop-up to update asset context variables""" + update_vars = get_context_var_changes() + if not update_vars: + # Nothing to change + print("Nothing to change, Houdini vars are already up to date.") + return + + message = "\n".join( + "${}: {} -> {}".format(var, old or "None", new or "None") + for var, (old, new, _is_directory) in update_vars.items() + ) + + # TODO: Use better UI! + parent = hou.ui.mainQtWindow() + dialog = popup.Popup(parent=parent) + dialog.setModal(True) + dialog.setWindowTitle("Houdini scene has outdated asset variables") + dialog.setMessage(message) + dialog.setButtonText("Fix") + + # on_show is the Fix button clicked callback + dialog.on_clicked.connect(update_houdini_vars_context) + + dialog.show() diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 6aa65deb89..f8db45c56b 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -300,6 +300,9 @@ def on_save(): log.info("Running callback on save..") + # update houdini vars + lib.update_houdini_vars_context_dialog() + nodes = lib.get_id_required_nodes() for node, new_id in lib.generate_ids(nodes): lib.set_id(node, new_id, overwrite=False) @@ -335,6 +338,9 @@ def on_open(): log.info("Running callback on open..") + # update houdini vars + lib.update_houdini_vars_context_dialog() + # Validate FPS after update_task_from_path to # ensure it is using correct FPS for the asset lib.validate_fps() @@ -399,6 +405,7 @@ def _set_context_settings(): """ lib.reset_framerange() + lib.update_houdini_vars_context() def on_pyblish_instance_toggled(instance, new_value, old_value): diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 730a627dc3..a0a7dcc2e4 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -187,13 +187,14 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): self.customize_node_look(instance_node) instance_data["instance_node"] = instance_node.path() + instance_data["instance_id"] = instance_node.path() instance = CreatedInstance( self.family, subset_name, instance_data, self) self._add_instance_to_context(instance) - imprint(instance_node, instance.data_to_store()) + self.imprint(instance_node, instance.data_to_store()) return instance except hou.Error as er: @@ -222,25 +223,41 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): self.cache_subsets(self.collection_shared_data) for instance in self.collection_shared_data[ "houdini_cached_subsets"].get(self.identifier, []): + + node_data = read(instance) + + # Node paths are always the full node path since that is unique + # Because it's the node's path it's not written into attributes + # but explicitly collected + node_path = instance.path() + node_data["instance_id"] = node_path + node_data["instance_node"] = node_path + created_instance = CreatedInstance.from_existing( - read(instance), self + node_data, self ) self._add_instance_to_context(created_instance) def update_instances(self, update_list): for created_inst, changes in update_list: instance_node = hou.node(created_inst.get("instance_node")) - new_values = { key: changes[key].new_value for key in changes.changed_keys } - imprint( + self.imprint( instance_node, new_values, update=True ) + def imprint(self, node, values, update=False): + # Never store instance node and instance id since that data comes + # from the node's path + values.pop("instance_node", None) + values.pop("instance_id", None) + imprint(node, values, update=update) + def remove_instances(self, instances): """Remove specified instance from the scene. diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index 5818a117eb..b2e32a70f9 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -86,6 +86,14 @@ openpype.hosts.houdini.api.lib.reset_framerange() ]]> + + + + + diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 4a150067e1..8b70b3ced7 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -1,15 +1,35 @@ # -*- coding: utf-8 -*- """Library of functions useful for 3dsmax pipeline.""" import contextlib +import logging import json from typing import Any, Dict, Union import six +from openpype.pipeline import get_current_project_name, colorspace +from openpype.settings import get_project_settings from openpype.pipeline.context_tools import ( get_current_project, get_current_project_asset) +from openpype.style import load_stylesheet from pymxs import runtime as rt + JSON_PREFIX = "JSON::" +log = logging.getLogger("openpype.hosts.max") + + +def get_main_window(): + """Acquire Max's main window""" + from qtpy import QtWidgets + top_widgets = QtWidgets.QApplication.topLevelWidgets() + name = "QmaxApplicationWindow" + for widget in top_widgets: + if ( + widget.inherits("QMainWindow") + and widget.metaObject().className() == name + ): + return widget + raise RuntimeError('Count not find 3dsMax main window.') def imprint(node_name: str, data: dict) -> bool: @@ -277,6 +297,7 @@ def set_context_setting(): """ reset_scene_resolution() reset_frame_range() + reset_colorspace() def get_max_version(): @@ -292,6 +313,14 @@ def get_max_version(): return max_info[7] +def is_headless(): + """Check if 3dsMax runs in batch mode. + If it returns True, it runs in 3dsbatch.exe + If it returns False, it runs in 3dsmax.exe + """ + return rt.maxops.isInNonInteractiveMode() + + @contextlib.contextmanager def viewport_camera(camera): original = rt.viewport.getCamera() @@ -314,6 +343,51 @@ def set_timeline(frameStart, frameEnd): return rt.animationRange +def reset_colorspace(): + """OCIO Configuration + Supports in 3dsMax 2024+ + + """ + if int(get_max_version()) < 2024: + return + project_name = get_current_project_name() + colorspace_mgr = rt.ColorPipelineMgr + project_settings = get_project_settings(project_name) + + max_config_data = colorspace.get_imageio_config( + project_name, "max", project_settings) + if max_config_data: + ocio_config_path = max_config_data["path"] + colorspace_mgr = rt.ColorPipelineMgr + colorspace_mgr.Mode = rt.Name("OCIO_Custom") + colorspace_mgr.OCIOConfigPath = ocio_config_path + + colorspace_mgr.OCIOConfigPath = ocio_config_path + + +def check_colorspace(): + parent = get_main_window() + if parent is None: + log.info("Skipping outdated pop-up " + "because Max main window can't be found.") + if int(get_max_version()) >= 2024: + color_mgr = rt.ColorPipelineMgr + project_name = get_current_project_name() + project_settings = get_project_settings(project_name) + max_config_data = colorspace.get_imageio_config( + project_name, "max", project_settings) + if max_config_data and color_mgr.Mode != rt.Name("OCIO_Custom"): + if not is_headless(): + from openpype.widgets import popup + dialog = popup.Popup(parent=parent) + dialog.setWindowTitle("Warning: Wrong OCIO Mode") + dialog.setMessage("This scene has wrong OCIO " + "Mode setting.") + dialog.setButtonText("Fix") + dialog.setStyleSheet(load_stylesheet()) + dialog.on_clicked.connect(reset_colorspace) + dialog.show() + def unique_namespace(namespace, format="%02d", prefix="", suffix="", con_suffix="CON"): """Return unique namespace diff --git a/openpype/hosts/max/api/menu.py b/openpype/hosts/max/api/menu.py index 066cc90039..364f9cd5c5 100644 --- a/openpype/hosts/max/api/menu.py +++ b/openpype/hosts/max/api/menu.py @@ -119,6 +119,10 @@ class OpenPypeMenu(object): frame_action.triggered.connect(self.frame_range_callback) openpype_menu.addAction(frame_action) + colorspace_action = QtWidgets.QAction("Set Colorspace", openpype_menu) + colorspace_action.triggered.connect(self.colorspace_callback) + openpype_menu.addAction(colorspace_action) + return openpype_menu def load_callback(self): @@ -148,3 +152,7 @@ class OpenPypeMenu(object): def frame_range_callback(self): """Callback to reset frame range""" return lib.reset_frame_range() + + def colorspace_callback(self): + """Callback to reset colorspace""" + return lib.reset_colorspace() diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index bd680a3d84..e46c4cabe7 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -57,6 +57,9 @@ class MaxHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): rt.callbacks.addScript(rt.Name('systemPostNew'), context_setting) + rt.callbacks.addScript(rt.Name('filePostOpen'), + lib.check_colorspace) + def has_unsaved_changes(self): # TODO: how to get it from 3dsmax? return True diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index 3389447cb0..fa6db073db 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -65,12 +65,12 @@ MS_CUSTOM_ATTRIB = """attributes "openPypeData" on button_add pressed do ( - current_selection = selectByName title:"Select Objects to add to + current_sel = selectByName title:"Select Objects to add to the Container" buttontext:"Add" filter:nodes_to_add - if current_selection == undefined then return False + if current_sel == undefined then return False temp_arr = #() i_node_arr = #() - for c in current_selection do + for c in current_sel do ( handle_name = node_to_name c node_ref = NodeTransformMonitor node:c @@ -89,15 +89,18 @@ MS_CUSTOM_ATTRIB = """attributes "openPypeData" on button_del pressed do ( - current_selection = selectByName title:"Select Objects to remove + current_sel = selectByName title:"Select Objects to remove from the Container" buttontext:"Remove" filter: nodes_to_rmv - if current_selection == undefined then return False + if current_sel == undefined or current_sel.count == 0 then + ( + return False + ) temp_arr = #() i_node_arr = #() new_i_node_arr = #() new_temp_arr = #() - for c in current_selection do + for c in current_sel do ( node_ref = NodeTransformMonitor node:c as string handle_name = node_to_name c diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 2dfa1520a9..a359e61921 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -34,6 +34,12 @@ class CollectRender(pyblish.api.InstancePlugin): files_by_aov.update(aovs) camera = rt.viewport.GetCamera() + if instance.data.get("members"): + camera_list = [member for member in instance.data["members"] + if rt.ClassOf(member) == rt.Camera.Classes] + if camera_list: + camera = camera_list[-1] + instance.data["cameras"] = [camera.name] if camera else None # noqa if "expectedFiles" not in instance.data: @@ -63,6 +69,17 @@ class CollectRender(pyblish.api.InstancePlugin): instance.data["colorspaceConfig"] = "" instance.data["colorspaceDisplay"] = "sRGB" instance.data["colorspaceView"] = "ACES 1.0 SDR-video" + + if int(get_max_version()) >= 2024: + colorspace_mgr = rt.ColorPipelineMgr # noqa + display = next( + (display for display in colorspace_mgr.GetDisplayList())) + view_transform = next( + (view for view in colorspace_mgr.GetViewList(display))) + instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath + instance.data["colorspaceDisplay"] = display + instance.data["colorspaceView"] = view_transform + instance.data["renderProducts"] = colorspace.ARenderProduct() instance.data["publishJobState"] = "Suspended" instance.data["attachTo"] = [] diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 7aeb45f46b..8e27a857d7 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -4,6 +4,7 @@ import pyblish.api from pymxs import runtime as rt from openpype.lib import BoolDef +from openpype.hosts.max.api.lib import get_max_version from openpype.pipeline.publish import OpenPypePyblishPluginMixin @@ -43,6 +44,17 @@ class CollectReview(pyblish.api.InstancePlugin, "dspSafeFrame": attr_values.get("dspSafeFrame"), "dspFrameNums": attr_values.get("dspFrameNums") } + + if int(get_max_version()) >= 2024: + colorspace_mgr = rt.ColorPipelineMgr # noqa + display = next( + (display for display in colorspace_mgr.GetDisplayList())) + view_transform = next( + (view for view in colorspace_mgr.GetViewList(display))) + instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath + instance.data["colorspaceDisplay"] = display + instance.data["colorspaceView"] = view_transform + # Enable ftrack functionality instance.data.setdefault("families", []).append('ftrack') @@ -54,7 +66,6 @@ class CollectReview(pyblish.api.InstancePlugin, @classmethod def get_attribute_defs(cls): - return [ BoolDef("dspGeometry", label="Geometry", diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 260241f5fc..dbb3578f08 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -6,6 +6,7 @@ from pyblish.api import Instance from maya import cmds # noqa import maya.mel as mel # noqa +from openpype.hosts.maya.api.lib import maintained_selection class FBXExtractor: @@ -53,7 +54,6 @@ class FBXExtractor: "bakeComplexEnd": int, "bakeComplexStep": int, "bakeResampleAnimation": bool, - "animationOnly": bool, "useSceneName": bool, "quaternion": str, # "euler" "shapes": bool, @@ -63,7 +63,10 @@ class FBXExtractor: "embeddedTextures": bool, "inputConnections": bool, "upAxis": str, # x, y or z, - "triangulate": bool + "triangulate": bool, + "fileVersion": str, + "skeletonDefinitions": bool, + "referencedAssetsContent": bool } @property @@ -94,7 +97,6 @@ class FBXExtractor: "bakeComplexEnd": end_frame, "bakeComplexStep": 1, "bakeResampleAnimation": True, - "animationOnly": False, "useSceneName": False, "quaternion": "euler", "shapes": True, @@ -104,7 +106,10 @@ class FBXExtractor: "embeddedTextures": False, "inputConnections": True, "upAxis": "y", - "triangulate": False + "triangulate": False, + "fileVersion": "FBX202000", + "skeletonDefinitions": False, + "referencedAssetsContent": False } def __init__(self, log=None): @@ -198,5 +203,9 @@ class FBXExtractor: path (str): Path to use for export. """ - cmds.select(members, r=True, noExpand=True) - mel.eval('FBXExport -f "{}" -s'.format(path)) + # The export requires forward slashes because we need + # to format it into a string in a mel expression + path = path.replace("\\", "/") + with maintained_selection(): + cmds.select(members, r=True, noExpand=True) + mel.eval('FBXExport -f "{}" -s'.format(path)) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 40b3419e73..510d4ecc85 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -183,6 +183,51 @@ def maintained_selection(): cmds.select(clear=True) +def get_namespace(node): + """Return namespace of given node""" + node_name = node.rsplit("|", 1)[-1] + if ":" in node_name: + return node_name.rsplit(":", 1)[0] + else: + return "" + + +def strip_namespace(node, namespace): + """Strip given namespace from node path. + + The namespace will only be stripped from names + if it starts with that namespace. If the namespace + occurs within another namespace it's not removed. + + Examples: + >>> strip_namespace("namespace:node", namespace="namespace:") + "node" + >>> strip_namespace("hello:world:node", namespace="hello:world") + "node" + >>> strip_namespace("hello:world:node", namespace="hello") + "world:node" + >>> strip_namespace("hello:world:node", namespace="world") + "hello:world:node" + >>> strip_namespace("ns:group|ns:node", namespace="ns") + "group|node" + + Returns: + str: Node name without given starting namespace. + + """ + + # Ensure namespace ends with `:` + if not namespace.endswith(":"): + namespace = "{}:".format(namespace) + + # The long path for a node can also have the namespace + # in its parents so we need to remove it from each + return "|".join( + name[len(namespace):] if name.startswith(namespace) else name + for name in node.split("|") + ) + + def get_custom_namespace(custom_namespace): """Return unique namespace. @@ -922,7 +967,7 @@ def no_display_layers(nodes): @contextlib.contextmanager -def namespaced(namespace, new=True): +def namespaced(namespace, new=True, relative_names=None): """Work inside namespace during context Args: @@ -934,15 +979,19 @@ def namespaced(namespace, new=True): """ original = cmds.namespaceInfo(cur=True, absoluteName=True) + original_relative_names = cmds.namespace(query=True, relativeNames=True) if new: namespace = unique_namespace(namespace) cmds.namespace(add=namespace) - + if relative_names is not None: + cmds.namespace(relativeNames=relative_names) try: cmds.namespace(set=namespace) yield namespace finally: cmds.namespace(set=original) + if relative_names is not None: + cmds.namespace(relativeNames=original_relative_names) @contextlib.contextmanager @@ -2571,7 +2620,7 @@ def bake_to_world_space(nodes, new_name = "{0}_baked".format(short_name) new_node = cmds.duplicate(node, name=new_name, - renameChildren=True)[0] + renameChildren=True)[0] # noqa # Connect all attributes on the node except for transform # attributes @@ -4100,14 +4149,19 @@ def create_rig_animation_instance( """ if options is None: options = {} - + name = context["representation"]["name"] output = next((node for node in nodes if node.endswith("out_SET")), None) controls = next((node for node in nodes if node.endswith("controls_SET")), None) + if name != "fbx": + assert output, "No out_SET in rig, this is a bug." + assert controls, "No controls_SET in rig, this is a bug." - assert output, "No out_SET in rig, this is a bug." - assert controls, "No controls_SET in rig, this is a bug." + anim_skeleton = next((node for node in nodes if + node.endswith("skeletonAnim_SET")), None) + skeleton_mesh = next((node for node in nodes if + node.endswith("skeletonMesh_SET")), None) # Find the roots amongst the loaded nodes roots = ( @@ -4119,9 +4173,7 @@ def create_rig_animation_instance( custom_subset = options.get("animationSubsetName") if custom_subset: formatting_data = { - # TODO remove 'asset_type' and replace 'asset_name' with 'asset' - "asset_name": context['asset']['name'], - "asset_type": context['asset']['type'], + "asset": context["asset"], "subset": context['subset']['name'], "family": ( context['subset']['data'].get('family') or @@ -4142,10 +4194,12 @@ def create_rig_animation_instance( host = registered_host() create_context = CreateContext(host) - # Create the animation instance + rig_sets = [output, controls, anim_skeleton, skeleton_mesh] + # Remove sets that this particular rig does not have + rig_sets = [s for s in rig_sets if s is not None] with maintained_selection(): - cmds.select([output, controls] + roots, noExpand=True) + cmds.select(rig_sets + roots, noExpand=True) create_context.create( creator_identifier=creator_identifier, variant=namespace, diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index 715f54686c..18a4ea0e9a 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -1,14 +1,13 @@ import os import logging +from functools import partial from qtpy import QtWidgets, QtGui import maya.utils import maya.cmds as cmds -from openpype.settings import get_project_settings from openpype.pipeline import ( - get_current_project_name, get_current_asset_name, get_current_task_name ) @@ -46,12 +45,12 @@ def get_context_label(): ) -def install(): +def install(project_settings): if cmds.about(batch=True): log.info("Skipping openpype.menu initialization in batch mode..") return - def deferred(): + def add_menu(): pyblish_icon = host_tools.get_pyblish_icon() parent_widget = get_main_window() cmds.menu( @@ -191,7 +190,7 @@ def install(): cmds.setParent(MENU_NAME, menu=True) - def add_scripts_menu(): + def add_scripts_menu(project_settings): try: import scriptsmenu.launchformaya as launchformaya except ImportError: @@ -201,9 +200,6 @@ def install(): ) return - # load configuration of custom menu - project_name = get_current_project_name() - project_settings = get_project_settings(project_name) config = project_settings["maya"]["scriptsmenu"]["definition"] _menu = project_settings["maya"]["scriptsmenu"]["name"] @@ -225,8 +221,9 @@ def install(): # so that it only gets called after Maya UI has initialized too. # This is crucial with Maya 2020+ which initializes without UI # first as a QCoreApplication - maya.utils.executeDeferred(deferred) - cmds.evalDeferred(add_scripts_menu, lowestPriority=True) + maya.utils.executeDeferred(add_menu) + cmds.evalDeferred(partial(add_scripts_menu, project_settings), + lowestPriority=True) def uninstall(): diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 3647ec0b6b..6b791c9665 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -28,8 +28,6 @@ from openpype.lib import ( from openpype.pipeline import ( legacy_io, get_current_project_name, - get_current_asset_name, - get_current_task_name, register_loader_plugin_path, register_inventory_action_path, register_creator_plugin_path, @@ -97,6 +95,8 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): self.log.info("Installing callbacks ... ") register_event_callback("init", on_init) + _set_project() + if lib.IS_HEADLESS: self.log.info(( "Running in headless mode, skipping Maya save/open/new" @@ -105,10 +105,9 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): return - _set_project() self._register_callbacks() - menu.install() + menu.install(project_settings) register_event_callback("save", on_save) register_event_callback("open", on_open) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 79fcf9bc8b..3b54954c8a 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -151,6 +151,7 @@ class MayaCreatorBase(object): # We never store the instance_node as value on the node since # it's the node name itself data.pop("instance_node", None) + data.pop("instance_id", None) # Don't store `families` since it's up to the creator itself # to define the initial publish families - not a stored attribute of @@ -227,6 +228,7 @@ class MayaCreatorBase(object): # Explicitly re-parse the node name node_data["instance_node"] = node + node_data["instance_id"] = node # If the creator plug-in specifies families = self.get_publish_families() @@ -601,6 +603,13 @@ class RenderlayerCreator(NewCreator, MayaCreatorBase): class Loader(LoaderPlugin): hosts = ["maya"] + load_settings = {} # defined in settings + + @classmethod + def apply_settings(cls, project_settings, system_settings): + super(Loader, cls).apply_settings(project_settings, system_settings) + cls.load_settings = project_settings['maya']['load'] + def get_custom_namespace_and_group(self, context, options, loader_key): """Queries Settings to get custom template for namespace and group. @@ -613,12 +622,9 @@ class Loader(LoaderPlugin): loader_key (str): key to get separate configuration from Settings ('reference_loader'|'import_loader') """ - options["attach_to_root"] = True - asset = context['asset'] - subset = context['subset'] - settings = get_project_settings(context['project']['name']) - custom_naming = settings['maya']['load'][loader_key] + options["attach_to_root"] = True + custom_naming = self.load_settings[loader_key] if not custom_naming['namespace']: raise LoadError("No namespace specified in " @@ -627,6 +633,8 @@ class Loader(LoaderPlugin): self.log.debug("No custom group_name, no group will be created.") options["attach_to_root"] = False + asset = context['asset'] + subset = context['subset'] formatting_data = { "asset_name": asset['name'], "asset_type": asset['type'], diff --git a/openpype/hosts/maya/hooks/pre_copy_mel.py b/openpype/hosts/maya/hooks/pre_copy_mel.py index 0fb5af149a..6cd2c69e20 100644 --- a/openpype/hosts/maya/hooks/pre_copy_mel.py +++ b/openpype/hosts/maya/hooks/pre_copy_mel.py @@ -7,7 +7,7 @@ class PreCopyMel(PreLaunchHook): Hook `GlobalHostDataHook` must be executed before this hook. """ - app_groups = {"maya"} + app_groups = {"maya", "mayapy"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hosts/maya/plugins/create/create_matchmove.py b/openpype/hosts/maya/plugins/create/create_matchmove.py new file mode 100644 index 0000000000..e64eb6a471 --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_matchmove.py @@ -0,0 +1,32 @@ +from openpype.hosts.maya.api import ( + lib, + plugin +) +from openpype.lib import BoolDef + + +class CreateMatchmove(plugin.MayaCreator): + """Instance for more complex setup of cameras. + + Might contain multiple cameras, geometries etc. + + It is expected to be extracted into .abc or .ma + """ + + identifier = "io.openpype.creators.maya.matchmove" + label = "Matchmove" + family = "matchmove" + icon = "video-camera" + + def get_instance_attr_defs(self): + + defs = lib.collect_animation_defs() + + defs.extend([ + BoolDef("bakeToWorldSpace", + label="Bake Cameras to World-Space", + tooltip="Bake Cameras to World-Space", + default=True), + ]) + + return defs diff --git a/openpype/hosts/maya/plugins/create/create_rig.py b/openpype/hosts/maya/plugins/create/create_rig.py index 345ab6c00d..acd5c98f89 100644 --- a/openpype/hosts/maya/plugins/create/create_rig.py +++ b/openpype/hosts/maya/plugins/create/create_rig.py @@ -20,6 +20,13 @@ class CreateRig(plugin.MayaCreator): instance_node = instance.get("instance_node") self.log.info("Creating Rig instance set up ...") + # TODO:change name (_controls_SET -> _rigs_SET) controls = cmds.sets(name=subset_name + "_controls_SET", empty=True) + # TODO:change name (_out_SET -> _geo_SET) pointcache = cmds.sets(name=subset_name + "_out_SET", empty=True) - cmds.sets([controls, pointcache], forceElement=instance_node) + skeleton = cmds.sets( + name=subset_name + "_skeletonAnim_SET", empty=True) + skeleton_mesh = cmds.sets( + name=subset_name + "_skeletonMesh_SET", empty=True) + cmds.sets([controls, pointcache, + skeleton, skeleton_mesh], forceElement=instance_node) diff --git a/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py b/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py new file mode 100644 index 0000000000..c9f9cd9ba8 --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py @@ -0,0 +1,39 @@ +from openpype.hosts.maya.api import ( + lib, + plugin +) +from openpype.lib import NumberDef + + +class CreateYetiCache(plugin.MayaCreator): + """Output for procedural plugin nodes of Yeti """ + + identifier = "io.openpype.creators.maya.unrealyeticache" + label = "Unreal - Yeti Cache" + family = "yeticacheUE" + icon = "pagelines" + + def get_instance_attr_defs(self): + + defs = [ + NumberDef("preroll", + label="Preroll", + minimum=0, + default=0, + decimals=0) + ] + + # Add animation data without step and handles + defs.extend(lib.collect_animation_defs()) + remove = {"step", "handleStart", "handleEnd"} + defs = [attr_def for attr_def in defs if attr_def.key not in remove] + + # Add samples after frame range + defs.append( + NumberDef("samples", + label="Samples", + default=3, + decimals=0) + ) + + return defs diff --git a/openpype/hosts/maya/plugins/load/_load_animation.py b/openpype/hosts/maya/plugins/load/_load_animation.py index 981b9ef434..0781735bc4 100644 --- a/openpype/hosts/maya/plugins/load/_load_animation.py +++ b/openpype/hosts/maya/plugins/load/_load_animation.py @@ -1,4 +1,46 @@ import openpype.hosts.maya.api.plugin +import maya.cmds as cmds + + +def _process_reference(file_url, name, namespace, options): + """Load files by referencing scene in Maya. + + Args: + file_url (str): fileapth of the objects to be loaded + name (str): subset name + namespace (str): namespace + options (dict): dict of storing the param + + Returns: + list: list of object nodes + """ + from openpype.hosts.maya.api.lib import unique_namespace + # Get name from asset being loaded + # Assuming name is subset name from the animation, we split the number + # suffix from the name to ensure the namespace is unique + name = name.split("_")[0] + ext = file_url.split(".")[-1] + namespace = unique_namespace( + "{}_".format(name), + format="%03d", + suffix="_{}".format(ext) + ) + + attach_to_root = options.get("attach_to_root", True) + group_name = options["group_name"] + + # no group shall be created + if not attach_to_root: + group_name = namespace + + nodes = cmds.file(file_url, + namespace=namespace, + sharedReferenceFile=False, + groupReference=attach_to_root, + groupName=group_name, + reference=True, + returnNewNodes=True) + return nodes class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): @@ -16,44 +58,42 @@ class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): def process_reference(self, context, name, namespace, options): - import maya.cmds as cmds - from openpype.hosts.maya.api.lib import unique_namespace - cmds.loadPlugin("AbcImport.mll", quiet=True) - # Prevent identical alembic nodes from being shared - # Create unique namespace for the cameras - - # Get name from asset being loaded - # Assuming name is subset name from the animation, we split the number - # suffix from the name to ensure the namespace is unique - name = name.split("_")[0] - namespace = unique_namespace( - "{}_".format(name), - format="%03d", - suffix="_abc" - ) - - attach_to_root = options.get("attach_to_root", True) - group_name = options["group_name"] - - # no group shall be created - if not attach_to_root: - group_name = namespace - # hero_001 (abc) # asset_counter{optional} path = self.filepath_from_context(context) file_url = self.prepare_root_value(path, context["project"]["name"]) - nodes = cmds.file(file_url, - namespace=namespace, - sharedReferenceFile=False, - groupReference=attach_to_root, - groupName=group_name, - reference=True, - returnNewNodes=True) + nodes = _process_reference(file_url, name, namespace, options) # load colorbleed ID attribute self[:] = nodes return nodes + + +class FbxLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): + """Loader to reference an Fbx files""" + + families = ["animation", + "camera"] + representations = ["fbx"] + + label = "Reference animation" + order = -10 + icon = "code-fork" + color = "orange" + + def process_reference(self, context, name, namespace, options): + + cmds.loadPlugin("fbx4maya.mll", quiet=True) + + path = self.filepath_from_context(context) + file_url = self.prepare_root_value(path, + context["project"]["name"]) + + nodes = _process_reference(file_url, name, namespace, options) + + self[:] = nodes + + return nodes diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index 265b15f4ae..90cadb31b1 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -1,12 +1,6 @@ from maya import cmds, mel -from openpype.client import ( - get_asset_by_id, - get_subset_by_id, - get_version_by_id, -) from openpype.pipeline import ( - get_current_project_name, load, get_representation_path, ) @@ -18,7 +12,7 @@ class AudioLoader(load.LoaderPlugin): """Specific loader of audio.""" families = ["audio"] - label = "Import audio" + label = "Load audio" representations = ["wav"] icon = "volume-up" color = "orange" @@ -27,10 +21,10 @@ class AudioLoader(load.LoaderPlugin): start_frame = cmds.playbackOptions(query=True, min=True) sound_node = cmds.sound( - file=context["representation"]["data"]["path"], offset=start_frame + file=self.filepath_from_context(context), offset=start_frame ) cmds.timeControl( - mel.eval("$tmpVar=$gPlayBackSlider"), + mel.eval("$gPlayBackSlider=$gPlayBackSlider"), edit=True, sound=sound_node, displaySound=True @@ -59,32 +53,50 @@ class AudioLoader(load.LoaderPlugin): assert audio_nodes is not None, "Audio node not found." audio_node = audio_nodes[0] + current_sound = cmds.timeControl( + mel.eval("$gPlayBackSlider=$gPlayBackSlider"), + query=True, + sound=True + ) + activate_sound = current_sound == audio_node + path = get_representation_path(representation) - cmds.setAttr("{}.filename".format(audio_node), path, type="string") + + cmds.sound( + audio_node, + edit=True, + file=path + ) + + # The source start + end does not automatically update itself to the + # length of thew new audio file, even though maya does do that when + # creating a new audio node. So to update we compute it manually. + # This would however override any source start and source end a user + # might have done on the original audio node after load. + audio_frame_count = cmds.getAttr("{}.frameCount".format(audio_node)) + audio_sample_rate = cmds.getAttr("{}.sampleRate".format(audio_node)) + duration_in_seconds = audio_frame_count / audio_sample_rate + fps = mel.eval('currentTimeUnitToFPS()') # workfile FPS + source_start = 0 + source_end = (duration_in_seconds * fps) + cmds.setAttr("{}.sourceStart".format(audio_node), source_start) + cmds.setAttr("{}.sourceEnd".format(audio_node), source_end) + + if activate_sound: + # maya by default deactivates it from timeline on file change + cmds.timeControl( + mel.eval("$gPlayBackSlider=$gPlayBackSlider"), + edit=True, + sound=audio_node, + displaySound=True + ) + cmds.setAttr( container["objectName"] + ".representation", str(representation["_id"]), type="string" ) - # Set frame range. - project_name = get_current_project_name() - version = get_version_by_id( - project_name, representation["parent"], fields=["parent"] - ) - subset = get_subset_by_id( - project_name, version["parent"], fields=["parent"] - ) - asset = get_asset_by_id( - project_name, subset["parent"], fields=["parent"] - ) - - source_start = 1 - asset["data"]["frameStart"] - source_end = asset["data"]["frameEnd"] - - cmds.setAttr("{}.sourceStart".format(audio_node), source_start) - cmds.setAttr("{}.sourceEnd".format(audio_node), source_end) - def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 61f337f501..4b704fa706 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -101,7 +101,8 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): "camerarig", "staticMesh", "skeletalMesh", - "mvLook"] + "mvLook", + "matchmove"] representations = ["ma", "abc", "fbx", "mb"] diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py new file mode 100644 index 0000000000..aef8765e9c --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from maya import cmds # noqa +import pyblish.api +from openpype.pipeline import OptionalPyblishPluginMixin + + +class CollectFbxAnimation(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Collect Animated Rig Data for FBX Extractor.""" + + order = pyblish.api.CollectorOrder + 0.2 + label = "Collect Fbx Animation" + hosts = ["maya"] + families = ["animation"] + optional = True + + def process(self, instance): + if not self.is_active(instance.data): + return + skeleton_sets = [ + i for i in instance + if i.endswith("skeletonAnim_SET") + ] + if not skeleton_sets: + return + + instance.data["families"].append("animation.fbx") + instance.data["animated_skeleton"] = [] + for skeleton_set in skeleton_sets: + skeleton_content = cmds.sets(skeleton_set, query=True) + self.log.debug( + "Collected animated skeleton data: {}".format( + skeleton_content + )) + if skeleton_content: + instance.data["animated_skeleton"] = skeleton_content diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_sets.py b/openpype/hosts/maya/plugins/publish/collect_rig_sets.py index 36a4211af1..34ff26a8b8 100644 --- a/openpype/hosts/maya/plugins/publish/collect_rig_sets.py +++ b/openpype/hosts/maya/plugins/publish/collect_rig_sets.py @@ -22,7 +22,8 @@ class CollectRigSets(pyblish.api.InstancePlugin): def process(self, instance): # Find required sets by suffix - searching = {"controls_SET", "out_SET"} + searching = {"controls_SET", "out_SET", + "skeletonAnim_SET", "skeletonMesh_SET"} found = {} for node in cmds.ls(instance, exactType="objectSet"): for suffix in searching: diff --git a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py new file mode 100644 index 0000000000..31f0eca88c --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +from maya import cmds # noqa +import pyblish.api + + +class CollectSkeletonMesh(pyblish.api.InstancePlugin): + """Collect Static Rig Data for FBX Extractor.""" + + order = pyblish.api.CollectorOrder + 0.2 + label = "Collect Skeleton Mesh" + hosts = ["maya"] + families = ["rig"] + + def process(self, instance): + skeleton_mesh_set = instance.data["rig_sets"].get( + "skeletonMesh_SET") + if not skeleton_mesh_set: + self.log.debug( + "No skeletonMesh_SET found. " + "Skipping collecting of skeleton mesh..." + ) + return + + # Store current frame to ensure single frame export + frame = cmds.currentTime(query=True) + instance.data["frameStart"] = frame + instance.data["frameEnd"] = frame + + instance.data["skeleton_mesh"] = [] + + skeleton_mesh_content = cmds.sets( + skeleton_mesh_set, query=True) or [] + if not skeleton_mesh_content: + self.log.debug( + "No object nodes in skeletonMesh_SET. " + "Skipping collecting of skeleton mesh..." + ) + return + instance.data["families"] += ["rig.fbx"] + instance.data["skeleton_mesh"] = skeleton_mesh_content + self.log.debug( + "Collected skeletonMesh_SET members: {}".format( + skeleton_mesh_content + )) diff --git a/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py b/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py index 4dcda29050..7a1516997a 100644 --- a/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py +++ b/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py @@ -39,7 +39,7 @@ class CollectYetiCache(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder + 0.45 label = "Collect Yeti Cache" - families = ["yetiRig", "yeticache"] + families = ["yetiRig", "yeticache", "yeticacheUE"] hosts = ["maya"] def process(self, instance): diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py index 4ec1399df4..43803743bc 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py @@ -6,17 +6,21 @@ from openpype.pipeline import publish from openpype.hosts.maya.api import lib -class ExtractCameraAlembic(publish.Extractor): +class ExtractCameraAlembic(publish.Extractor, + publish.OptionalPyblishPluginMixin): """Extract a Camera as Alembic. - The cameras gets baked to world space by default. Only when the instance's + The camera gets baked to world space by default. Only when the instance's `bakeToWorldSpace` is set to False it will include its full hierarchy. + 'camera' family expects only single camera, if multiple cameras are needed, + 'matchmove' is better choice. + """ - label = "Camera (Alembic)" + label = "Extract Camera (Alembic)" hosts = ["maya"] - families = ["camera"] + families = ["camera", "matchmove"] bake_attributes = [] def process(self, instance): @@ -35,10 +39,11 @@ class ExtractCameraAlembic(publish.Extractor): # validate required settings assert isinstance(step, float), "Step must be a float value" - camera = cameras[0] # Define extract output file path dir_path = self.staging_dir(instance) + if not os.path.exists(dir_path): + os.makedirs(dir_path) filename = "{0}.abc".format(instance.name) path = os.path.join(dir_path, filename) @@ -64,9 +69,10 @@ class ExtractCameraAlembic(publish.Extractor): # if baked, drop the camera hierarchy to maintain # clean output and backwards compatibility - camera_root = cmds.listRelatives( - camera, parent=True, fullPath=True)[0] - job_str += ' -root {0}'.format(camera_root) + camera_roots = cmds.listRelatives( + cameras, parent=True, fullPath=True) + for camera_root in camera_roots: + job_str += ' -root {0}'.format(camera_root) for member in members: descendants = cmds.listRelatives(member, diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py index a50a8f0dfa..38cf00bbdd 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py @@ -2,11 +2,15 @@ """Extract camera as Maya Scene.""" import os import itertools +import contextlib from maya import cmds from openpype.pipeline import publish from openpype.hosts.maya.api import lib +from openpype.lib import ( + BoolDef +) def massage_ma_file(path): @@ -78,7 +82,8 @@ def unlock(plug): cmds.disconnectAttr(source, destination) -class ExtractCameraMayaScene(publish.Extractor): +class ExtractCameraMayaScene(publish.Extractor, + publish.OptionalPyblishPluginMixin): """Extract a Camera as Maya Scene. This will create a duplicate of the camera that will be baked *with* @@ -88,17 +93,22 @@ class ExtractCameraMayaScene(publish.Extractor): The cameras gets baked to world space by default. Only when the instance's `bakeToWorldSpace` is set to False it will include its full hierarchy. + 'camera' family expects only single camera, if multiple cameras are needed, + 'matchmove' is better choice. + Note: The extracted Maya ascii file gets "massaged" removing the uuid values so they are valid for older versions of Fusion (e.g. 6.4) """ - label = "Camera (Maya Scene)" + label = "Extract Camera (Maya Scene)" hosts = ["maya"] - families = ["camera"] + families = ["camera", "matchmove"] scene_type = "ma" + keep_image_planes = True + def process(self, instance): """Plugin entry point.""" # get settings @@ -131,15 +141,15 @@ class ExtractCameraMayaScene(publish.Extractor): "bake to world space is ignored...") # get cameras - members = cmds.ls(instance.data['setMembers'], leaf=True, shapes=True, - long=True, dag=True) - cameras = cmds.ls(members, leaf=True, shapes=True, long=True, - dag=True, type="camera") + members = set(cmds.ls(instance.data['setMembers'], leaf=True, + shapes=True, long=True, dag=True)) + cameras = set(cmds.ls(members, leaf=True, shapes=True, long=True, + dag=True, type="camera")) # validate required settings assert isinstance(step, float), "Step must be a float value" - camera = cameras[0] - transform = cmds.listRelatives(camera, parent=True, fullPath=True) + transforms = cmds.listRelatives(list(cameras), + parent=True, fullPath=True) # Define extract output file path dir_path = self.staging_dir(instance) @@ -151,23 +161,21 @@ class ExtractCameraMayaScene(publish.Extractor): with lib.evaluation("off"): with lib.suspended_refresh(): if bake_to_worldspace: - self.log.debug( - "Performing camera bakes: {}".format(transform)) baked = lib.bake_to_world_space( - transform, + transforms, frame_range=[start, end], step=step ) - baked_camera_shapes = cmds.ls(baked, - type="camera", - dag=True, - shapes=True, - long=True) + baked_camera_shapes = set(cmds.ls(baked, + type="camera", + dag=True, + shapes=True, + long=True)) - members = members + baked_camera_shapes - members.remove(camera) + members.update(baked_camera_shapes) + members.difference_update(cameras) else: - baked_camera_shapes = cmds.ls(cameras, + baked_camera_shapes = cmds.ls(list(cameras), type="camera", dag=True, shapes=True, @@ -186,19 +194,28 @@ class ExtractCameraMayaScene(publish.Extractor): unlock(plug) cmds.setAttr(plug, value) - self.log.debug("Performing extraction..") - cmds.select(cmds.ls(members, dag=True, - shapes=True, long=True), noExpand=True) - cmds.file(path, - force=True, - typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 - exportSelected=True, - preserveReferences=False, - constructionHistory=False, - channels=True, # allow animation - constraints=False, - shader=False, - expressions=False) + attr_values = self.get_attr_values_from_data( + instance.data) + keep_image_planes = attr_values.get("keep_image_planes") + + with transfer_image_planes(sorted(cameras), + sorted(baked_camera_shapes), + keep_image_planes): + + self.log.info("Performing extraction..") + cmds.select(cmds.ls(list(members), dag=True, + shapes=True, long=True), + noExpand=True) + cmds.file(path, + force=True, + typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 + exportSelected=True, + preserveReferences=False, + constructionHistory=False, + channels=True, # allow animation + constraints=False, + shader=False, + expressions=False) # Delete the baked hierarchy if bake_to_worldspace: @@ -219,3 +236,62 @@ class ExtractCameraMayaScene(publish.Extractor): self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, path)) + + @classmethod + def get_attribute_defs(cls): + defs = super(ExtractCameraMayaScene, cls).get_attribute_defs() + + defs.extend([ + BoolDef("keep_image_planes", + label="Keep Image Planes", + tooltip="Preserving connected image planes on camera", + default=cls.keep_image_planes), + + ]) + + return defs + + +@contextlib.contextmanager +def transfer_image_planes(source_cameras, target_cameras, + keep_input_connections): + """Reattaches image planes to baked or original cameras. + + Baked cameras are duplicates of original ones. + This attaches it to duplicated camera properly and after + export it reattaches it back to original to keep image plane in workfile. + """ + originals = {} + try: + for source_camera, target_camera in zip(source_cameras, + target_cameras): + image_planes = cmds.listConnections(source_camera, + type="imagePlane") or [] + + # Split of the parent path they are attached - we want + # the image plane node name. + # TODO: Does this still mean the image plane name is unique? + image_planes = [x.split("->", 1)[1] for x in image_planes] + + if not image_planes: + continue + + originals[source_camera] = [] + for image_plane in image_planes: + if keep_input_connections: + if source_camera == target_camera: + continue + _attach_image_plane(target_camera, image_plane) + else: # explicitly dettaching image planes + cmds.imagePlane(image_plane, edit=True, detach=True) + originals[source_camera].append(image_plane) + yield + finally: + for camera, image_planes in originals.items(): + for image_plane in image_planes: + _attach_image_plane(camera, image_plane) + + +def _attach_image_plane(camera, image_plane): + cmds.imagePlane(image_plane, edit=True, detach=True) + cmds.imagePlane(image_plane, edit=True, camera=camera) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py new file mode 100644 index 0000000000..8288bc9329 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +import os + +from maya import cmds # noqa +import pyblish.api + +from openpype.pipeline import publish +from openpype.hosts.maya.api import fbx +from openpype.hosts.maya.api.lib import ( + namespaced, get_namespace, strip_namespace +) + + +class ExtractFBXAnimation(publish.Extractor): + """Extract Rig in FBX format from Maya. + + This extracts the rig in fbx with the constraints + and referenced asset content included. + This also optionally extract animated rig in fbx with + geometries included. + + """ + order = pyblish.api.ExtractorOrder + label = "Extract Animation (FBX)" + hosts = ["maya"] + families = ["animation.fbx"] + + def process(self, instance): + # Define output path + staging_dir = self.staging_dir(instance) + filename = "{0}.fbx".format(instance.name) + path = os.path.join(staging_dir, filename) + path = path.replace("\\", "/") + + fbx_exporter = fbx.FBXExtractor(log=self.log) + out_members = instance.data.get("animated_skeleton", []) + # Export + instance.data["constraints"] = True + instance.data["skeletonDefinitions"] = True + instance.data["referencedAssetsContent"] = True + fbx_exporter.set_options_from_instance(instance) + # Export from the rig's namespace so that the exported + # FBX does not include the namespace but preserves the node + # names as existing in the rig workfile + namespace = get_namespace(out_members[0]) + relative_out_members = [ + strip_namespace(node, namespace) for node in out_members + ] + with namespaced( + ":" + namespace, + new=False, + relative_names=True + ) as namespace: + fbx_exporter.export(relative_out_members, path) + + representations = instance.data.setdefault("representations", []) + representations.append({ + 'name': 'fbx', + 'ext': 'fbx', + 'files': filename, + "stagingDir": staging_dir + }) + + self.log.debug( + "Extracted FBX animation to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py new file mode 100644 index 0000000000..50c1fb3bde --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +import os + +from maya import cmds # noqa +import pyblish.api + +from openpype.pipeline import publish +from openpype.pipeline.publish import OptionalPyblishPluginMixin +from openpype.hosts.maya.api import fbx + + +class ExtractSkeletonMesh(publish.Extractor, + OptionalPyblishPluginMixin): + """Extract Rig in FBX format from Maya. + + This extracts the rig in fbx with the constraints + and referenced asset content included. + This also optionally extract animated rig in fbx with + geometries included. + + """ + order = pyblish.api.ExtractorOrder + label = "Extract Skeleton Mesh" + hosts = ["maya"] + families = ["rig.fbx"] + + def process(self, instance): + if not self.is_active(instance.data): + return + # Define output path + staging_dir = self.staging_dir(instance) + filename = "{0}.fbx".format(instance.name) + path = os.path.join(staging_dir, filename) + + fbx_exporter = fbx.FBXExtractor(log=self.log) + out_set = instance.data.get("skeleton_mesh", []) + + instance.data["constraints"] = True + instance.data["skeletonDefinitions"] = True + + fbx_exporter.set_options_from_instance(instance) + + # Export + fbx_exporter.export(out_set, path) + + representations = instance.data.setdefault("representations", []) + representations.append({ + 'name': 'fbx', + 'ext': 'fbx', + 'files': filename, + "stagingDir": staging_dir + }) + + self.log.debug("Extract FBX to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py b/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py new file mode 100644 index 0000000000..963285093e --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py @@ -0,0 +1,61 @@ +import os + +from maya import cmds + +from openpype.pipeline import publish + + +class ExtractYetiCache(publish.Extractor): + """Producing Yeti cache files using scene time range. + + This will extract Yeti cache file sequence and fur settings. + """ + + label = "Extract Yeti Cache" + hosts = ["maya"] + families = ["yeticacheUE"] + + def process(self, instance): + + yeti_nodes = cmds.ls(instance, type="pgYetiMaya") + if not yeti_nodes: + raise RuntimeError("No pgYetiMaya nodes found in the instance") + + # Define extract output file path + dirname = self.staging_dir(instance) + + # Collect information for writing cache + start_frame = instance.data["frameStartHandle"] + end_frame = instance.data["frameEndHandle"] + preroll = instance.data["preroll"] + if preroll > 0: + start_frame -= preroll + + kwargs = {} + samples = instance.data.get("samples", 0) + if samples == 0: + kwargs.update({"sampleTimes": "0.0 1.0"}) + else: + kwargs.update({"samples": samples}) + + self.log.debug(f"Writing out cache {start_frame} - {end_frame}") + filename = f"{instance.name}.abc" + path = os.path.join(dirname, filename) + cmds.pgYetiCommand(yeti_nodes, + writeAlembic=path, + range=(start_frame, end_frame), + asUnrealAbc=True, + **kwargs) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'abc', + 'ext': 'abc', + 'files': filename, + 'stagingDir': dirname + } + instance.data["representations"].append(representation) + + self.log.debug(f"Extracted {instance} to {dirname}") diff --git a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py new file mode 100644 index 0000000000..4537892d6d --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py @@ -0,0 +1,66 @@ +import pyblish.api +import openpype.hosts.maya.api.action +from openpype.pipeline.publish import ( + PublishValidationError, + ValidateContentsOrder +) +from maya import cmds + + +class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): + """Validate all nodes in skeletonAnim_SET are referenced""" + + order = ValidateContentsOrder + hosts = ["maya"] + families = ["animation.fbx"] + label = "Animated Reference Rig" + accepted_controllers = ["transform", "locator"] + actions = [openpype.hosts.maya.api.action.SelectInvalidAction] + + def process(self, instance): + animated_sets = instance.data.get("animated_skeleton", []) + if not animated_sets: + self.log.debug( + "No nodes found in skeletonAnim_SET. " + "Skipping validation of animated reference rig..." + ) + return + + for animated_reference in animated_sets: + is_referenced = cmds.referenceQuery( + animated_reference, isNodeReferenced=True) + if not bool(is_referenced): + raise PublishValidationError( + "All the content in skeletonAnim_SET" + " should be referenced nodes" + ) + invalid_controls = self.validate_controls(animated_sets) + if invalid_controls: + raise PublishValidationError( + "All the content in skeletonAnim_SET" + " should be transforms" + ) + + @classmethod + def validate_controls(self, set_members): + """Check if the controller set contains only accepted node types. + + Checks if all its set members are within the hierarchy of the root + Checks if the node types of the set members valid + + Args: + set_members: list of nodes of the skeleton_anim_set + hierarchy: list of nodes which reside under the root node + + Returns: + errors (list) + """ + + # Validate control types + invalid = [] + set_members = cmds.ls(set_members, long=True) + for node in set_members: + if cmds.nodeType(node) not in self.accepted_controllers: + invalid.append(node) + + return invalid diff --git a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py index 9f47bf7a3d..cb5c68e4ab 100644 --- a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py @@ -30,18 +30,21 @@ class ValidatePluginPathAttributes(pyblish.api.InstancePlugin): def get_invalid(cls, instance): invalid = list() - file_attr = cls.attribute - if not file_attr: + file_attrs = cls.attribute + if not file_attrs: return invalid # Consider only valid node types to avoid "Unknown object type" warning all_node_types = set(cmds.allNodeTypes()) - node_types = [key for key in file_attr.keys() if key in all_node_types] + node_types = [ + key for key in file_attrs.keys() + if key in all_node_types + ] for node, node_type in pairwise(cmds.ls(type=node_types, showType=True)): # get the filepath - file_attr = "{}.{}".format(node, file_attr[node_type]) + file_attr = "{}.{}".format(node, file_attrs[node_type]) filepath = cmds.getAttr(file_attr) if filepath and not os.path.exists(filepath): diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py new file mode 100644 index 0000000000..91b473b250 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -0,0 +1,117 @@ +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) +from maya import cmds +from openpype.pipeline.publish import RepairAction +from openpype.hosts.maya.api import lib +from openpype.hosts.maya.api.lib import reset_scene_resolution + + +class ValidateResolution(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validate the render resolution setting aligned with DB""" + + order = pyblish.api.ValidatorOrder + families = ["renderlayer"] + hosts = ["maya"] + label = "Validate Resolution" + actions = [RepairAction] + optional = True + + def process(self, instance): + if not self.is_active(instance.data): + return + invalid = self.get_invalid_resolution(instance) + if invalid: + raise PublishValidationError( + "Render resolution is invalid. See log for details.", + description=( + "Wrong render resolution setting. " + "Please use repair button to fix it.\n\n" + "If current renderer is V-Ray, " + "make sure vraySettings node has been created." + ) + ) + + @classmethod + def get_invalid_resolution(cls, instance): + width, height, pixelAspect = cls.get_db_resolution(instance) + current_renderer = instance.data["renderer"] + layer = instance.data["renderlayer"] + invalid = False + if current_renderer == "vray": + vray_node = "vraySettings" + if cmds.objExists(vray_node): + current_width = lib.get_attr_in_layer( + "{}.width".format(vray_node), layer=layer) + current_height = lib.get_attr_in_layer( + "{}.height".format(vray_node), layer=layer) + current_pixelAspect = lib.get_attr_in_layer( + "{}.pixelAspect".format(vray_node), layer=layer + ) + else: + cls.log.error( + "Can't detect VRay resolution because there is no node " + "named: `{}`".format(vray_node) + ) + return True + else: + current_width = lib.get_attr_in_layer( + "defaultResolution.width", layer=layer) + current_height = lib.get_attr_in_layer( + "defaultResolution.height", layer=layer) + current_pixelAspect = lib.get_attr_in_layer( + "defaultResolution.pixelAspect", layer=layer + ) + if current_width != width or current_height != height: + cls.log.error( + "Render resolution {}x{} does not match " + "asset resolution {}x{}".format( + current_width, current_height, + width, height + )) + invalid = True + if current_pixelAspect != pixelAspect: + cls.log.error( + "Render pixel aspect {} does not match " + "asset pixel aspect {}".format( + current_pixelAspect, pixelAspect + )) + invalid = True + return invalid + + @classmethod + def get_db_resolution(cls, instance): + asset_doc = instance.data["assetEntity"] + project_doc = instance.context.data["projectEntity"] + for data in [asset_doc["data"], project_doc["data"]]: + if ( + "resolutionWidth" in data and + "resolutionHeight" in data and + "pixelAspect" in data + ): + width = data["resolutionWidth"] + height = data["resolutionHeight"] + pixelAspect = data["pixelAspect"] + return int(width), int(height), float(pixelAspect) + + # Defaults if not found in asset document or project document + return 1920, 1080, 1.0 + + @classmethod + def repair(cls, instance): + # Usually without renderlayer overrides the renderlayers + # all share the same resolution value - so fixing the first + # will have fixed all the others too. It's much faster to + # check whether it's invalid first instead of switching + # into all layers individually + if not cls.get_invalid_resolution(instance): + cls.log.debug( + "Nothing to repair on instance: {}".format(instance) + ) + return + layer_node = instance.data['setMembers'] + with lib.renderlayer(layer_node): + reset_scene_resolution() diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index 23f031a5db..106b4024e2 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -1,6 +1,6 @@ import pyblish.api from maya import cmds - +import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, ValidateContentsOrder @@ -20,33 +20,27 @@ class ValidateRigContents(pyblish.api.InstancePlugin): label = "Rig Contents" hosts = ["maya"] families = ["rig"] + action = [openpype.hosts.maya.api.action.SelectInvalidAction] accepted_output = ["mesh", "transform"] accepted_controllers = ["transform"] def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + raise PublishValidationError( + "Invalid rig content. See log for details.") + + @classmethod + def get_invalid(cls, instance): # Find required sets by suffix - required = ["controls_SET", "out_SET"] - missing = [ - key for key in required if key not in instance.data["rig_sets"] - ] - if missing: - raise PublishValidationError( - "%s is missing sets: %s" % (instance, ", ".join(missing)) - ) + required, rig_sets = cls.get_nodes(instance) - controls_set = instance.data["rig_sets"]["controls_SET"] - out_set = instance.data["rig_sets"]["out_SET"] + cls.validate_missing_objectsets(instance, required, rig_sets) - # Ensure there are at least some transforms or dag nodes - # in the rig instance - set_members = instance.data['setMembers'] - if not cmds.ls(set_members, type="dagNode", long=True): - raise PublishValidationError( - "No dag nodes in the pointcache instance. " - "(Empty instance?)" - ) + controls_set = rig_sets["controls_SET"] + out_set = rig_sets["out_SET"] # Ensure contents in sets and retrieve long path for all objects output_content = cmds.sets(out_set, query=True) or [] @@ -61,49 +55,92 @@ class ValidateRigContents(pyblish.api.InstancePlugin): ) controls_content = cmds.ls(controls_content, long=True) - # Validate members are inside the hierarchy from root node - root_nodes = cmds.ls(set_members, assemblies=True, long=True) - hierarchy = cmds.listRelatives(root_nodes, allDescendents=True, - fullPath=True) + root_nodes - hierarchy = set(hierarchy) - - invalid_hierarchy = [] - for node in output_content: - if node not in hierarchy: - invalid_hierarchy.append(node) - for node in controls_content: - if node not in hierarchy: - invalid_hierarchy.append(node) + rig_content = output_content + controls_content + invalid_hierarchy = cls.invalid_hierarchy(instance, rig_content) # Additional validations - invalid_geometry = self.validate_geometry(output_content) - invalid_controls = self.validate_controls(controls_content) + invalid_geometry = cls.validate_geometry(output_content) + invalid_controls = cls.validate_controls(controls_content) error = False if invalid_hierarchy: - self.log.error("Found nodes which reside outside of root group " + cls.log.error("Found nodes which reside outside of root group " "while they are set up for publishing." "\n%s" % invalid_hierarchy) error = True if invalid_controls: - self.log.error("Only transforms can be part of the controls_SET." + cls.log.error("Only transforms can be part of the controls_SET." "\n%s" % invalid_controls) error = True if invalid_geometry: - self.log.error("Only meshes can be part of the out_SET\n%s" + cls.log.error("Only meshes can be part of the out_SET\n%s" % invalid_geometry) error = True - if error: + return invalid_hierarchy + invalid_controls + invalid_geometry + + @classmethod + def validate_missing_objectsets(cls, instance, + required_objsets, rig_sets): + """Validate missing objectsets in rig sets + + Args: + instance (str): instance + required_objsets (list): list of objectset names + rig_sets (list): list of rig sets + + Raises: + PublishValidationError: When the error is raised, it will show + which instance has the missing object sets + """ + missing = [ + key for key in required_objsets if key not in rig_sets + ] + if missing: raise PublishValidationError( - "Invalid rig content. See log for details.") + "%s is missing sets: %s" % (instance, ", ".join(missing)) + ) - def validate_geometry(self, set_members): - """Check if the out set passes the validations + @classmethod + def invalid_hierarchy(cls, instance, content): + """ + Check if all rig set members are within the hierarchy of the rig root - Checks if all its set members are within the hierarchy of the root + Args: + instance (str): instance + content (list): list of content from rig sets + + Raises: + PublishValidationError: It means no dag nodes in + the rig instance + + Returns: + list: invalid hierarchy + """ + # Ensure there are at least some transforms or dag nodes + # in the rig instance + set_members = instance.data['setMembers'] + if not cmds.ls(set_members, type="dagNode", long=True): + raise PublishValidationError( + "No dag nodes in the rig instance. " + "(Empty instance?)" + ) + # Validate members are inside the hierarchy from root node + root_nodes = cmds.ls(set_members, assemblies=True, long=True) + hierarchy = cmds.listRelatives(root_nodes, allDescendents=True, + fullPath=True) + root_nodes + hierarchy = set(hierarchy) + invalid_hierarchy = [] + for node in content: + if node not in hierarchy: + invalid_hierarchy.append(node) + return invalid_hierarchy + + @classmethod + def validate_geometry(cls, set_members): + """ Checks if the node types of the set members valid Args: @@ -122,15 +159,13 @@ class ValidateRigContents(pyblish.api.InstancePlugin): fullPath=True) or [] all_shapes = cmds.ls(set_members + shapes, long=True, shapes=True) for shape in all_shapes: - if cmds.nodeType(shape) not in self.accepted_output: + if cmds.nodeType(shape) not in cls.accepted_output: invalid.append(shape) - return invalid - - def validate_controls(self, set_members): - """Check if the controller set passes the validations - - Checks if all its set members are within the hierarchy of the root + @classmethod + def validate_controls(cls, set_members): + """ + Checks if the control set members are allowed node types. Checks if the node types of the set members valid Args: @@ -144,7 +179,80 @@ class ValidateRigContents(pyblish.api.InstancePlugin): # Validate control types invalid = [] for node in set_members: - if cmds.nodeType(node) not in self.accepted_controllers: + if cmds.nodeType(node) not in cls.accepted_controllers: invalid.append(node) return invalid + + @classmethod + def get_nodes(cls, instance): + """Get the target objectsets and rig sets nodes + + Args: + instance (str): instance + + Returns: + tuple: 2-tuple of list of objectsets, + list of rig sets nodes + """ + objectsets = ["controls_SET", "out_SET"] + rig_sets_nodes = instance.data.get("rig_sets", []) + return objectsets, rig_sets_nodes + + +class ValidateSkeletonRigContents(ValidateRigContents): + """Ensure skeleton rigs contains pipeline-critical content + + The rigs optionally contain at least two object sets: + "skeletonMesh_SET" - Set of the skinned meshes + with bone hierarchies + + """ + + order = ValidateContentsOrder + label = "Skeleton Rig Contents" + hosts = ["maya"] + families = ["rig.fbx"] + + @classmethod + def get_invalid(cls, instance): + objectsets, skeleton_mesh_nodes = cls.get_nodes(instance) + cls.validate_missing_objectsets( + instance, objectsets, instance.data["rig_sets"]) + + # Ensure contents in sets and retrieve long path for all objects + output_content = instance.data.get("skeleton_mesh", []) + output_content = cmds.ls(skeleton_mesh_nodes, long=True) + + invalid_hierarchy = cls.invalid_hierarchy( + instance, output_content) + invalid_geometry = cls.validate_geometry(output_content) + + error = False + if invalid_hierarchy: + cls.log.error("Found nodes which reside outside of root group " + "while they are set up for publishing." + "\n%s" % invalid_hierarchy) + error = True + if invalid_geometry: + cls.log.error("Found nodes which reside outside of root group " + "while they are set up for publishing." + "\n%s" % invalid_hierarchy) + error = True + if error: + return invalid_hierarchy + invalid_geometry + + @classmethod + def get_nodes(cls, instance): + """Get the target objectsets and rig sets nodes + + Args: + instance (str): instance + + Returns: + tuple: 2-tuple of list of objectsets, + list of rig sets nodes + """ + objectsets = ["skeletonMesh_SET"] + skeleton_mesh_nodes = instance.data.get("skeleton_mesh", []) + return objectsets, skeleton_mesh_nodes diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py index a3828f871b..82248c57b3 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py @@ -59,7 +59,7 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): - controls_set = instance.data["rig_sets"].get("controls_SET") + controls_set = cls.get_node(instance) if not controls_set: cls.log.error( "Must have 'controls_SET' in rig instance" @@ -189,7 +189,7 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): @classmethod def repair(cls, instance): - controls_set = instance.data["rig_sets"].get("controls_SET") + controls_set = cls.get_node(instance) if not controls_set: cls.log.error( "Unable to repair because no 'controls_SET' found in rig " @@ -228,3 +228,64 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): default = cls.CONTROLLER_DEFAULTS[attr] cls.log.info("Setting %s to %s" % (plug, default)) cmds.setAttr(plug, default) + + @classmethod + def get_node(cls, instance): + """Get target object nodes from controls_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from controls_SET + """ + return instance.data["rig_sets"].get("controls_SET") + + +class ValidateSkeletonRigControllers(ValidateRigControllers): + """Validate rig controller for skeletonAnim_SET + + Controls must have the transformation attributes on their default + values of translate zero, rotate zero and scale one when they are + unlocked attributes. + + Unlocked keyable attributes may not have any incoming connections. If + these connections are required for the rig then lock the attributes. + + The visibility attribute must be locked. + + Note that `repair` will: + - Lock all visibility attributes + - Reset all default values for translate, rotate, scale + - Break all incoming connections to keyable attributes + + """ + order = ValidateContentsOrder + 0.05 + label = "Skeleton Rig Controllers" + hosts = ["maya"] + families = ["rig.fbx"] + + # Default controller values + CONTROLLER_DEFAULTS = { + "translateX": 0, + "translateY": 0, + "translateZ": 0, + "rotateX": 0, + "rotateY": 0, + "rotateZ": 0, + "scaleX": 1, + "scaleY": 1, + "scaleZ": 1 + } + + @classmethod + def get_node(cls, instance): + """Get target object nodes from skeletonMesh_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from skeletonMesh_SET + """ + return instance.data["rig_sets"].get("skeletonMesh_SET") diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py index fbd510c683..80ac0f27e6 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py @@ -46,7 +46,7 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin): def get_invalid(cls, instance): """Get all nodes which do not match the criteria""" - out_set = instance.data["rig_sets"].get("out_SET") + out_set = cls.get_node(instance) if not out_set: return [] @@ -85,3 +85,45 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin): continue lib.set_id(node, sibling_id, overwrite=True) + + @classmethod + def get_node(cls, instance): + """Get target object nodes from out_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from out_SET + """ + return instance.data["rig_sets"].get("out_SET") + + +class ValidateSkeletonRigOutSetNodeIds(ValidateRigOutSetNodeIds): + """Validate if deformed shapes have related IDs to the original shapes + from skeleton set. + + When a deformer is applied in the scene on a referenced mesh that already + had deformers then Maya will create a new shape node for the mesh that + does not have the original id. This validator checks whether the ids are + valid on all the shape nodes in the instance. + + """ + + order = ValidateContentsOrder + families = ["rig.fbx"] + hosts = ['maya'] + label = 'Skeleton Rig Out Set Node Ids' + + @classmethod + def get_node(cls, instance): + """Get target object nodes from skeletonMesh_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from skeletonMesh_SET + """ + return instance.data["rig_sets"].get( + "skeletonMesh_SET") diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py index 24fb36eb8b..343d8e6924 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py @@ -47,7 +47,7 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin): invalid = {} if compute: - out_set = instance.data["rig_sets"].get("out_SET") + out_set = cls.get_node(instance) if not out_set: instance.data["mismatched_output_ids"] = invalid return invalid @@ -115,3 +115,40 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin): "Multiple matched ids found. Please repair manually: " "{}".format(multiple_ids_match) ) + + @classmethod + def get_node(cls, instance): + """Get target object nodes from out_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from out_SET + """ + return instance.data["rig_sets"].get("out_SET") + + +class ValidateSkeletonRigOutputIds(ValidateRigOutputIds): + """Validate rig output ids from the skeleton sets. + + Ids must share the same id as similarly named nodes in the scene. This is + to ensure the id from the model is preserved through animation. + + """ + order = ValidateContentsOrder + 0.05 + label = "Skeleton Rig Output Ids" + hosts = ["maya"] + families = ["rig.fbx"] + + @classmethod + def get_node(cls, instance): + """Get target object nodes from skeletonMesh_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from skeletonMesh_SET + """ + return instance.data["rig_sets"].get("skeletonMesh_SET") diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py new file mode 100644 index 0000000000..1dbe1c454c --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +"""Plugin for validating naming conventions.""" +from maya import cmds + +import pyblish.api + +from openpype.pipeline.publish import ( + ValidateContentsOrder, + OptionalPyblishPluginMixin, + PublishValidationError +) + + +class ValidateSkeletonTopGroupHierarchy(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validates top group hierarchy in the SETs + Make sure the object inside the SETs are always top + group of the hierarchy + + """ + order = ValidateContentsOrder + 0.05 + label = "Skeleton Rig Top Group Hierarchy" + families = ["rig.fbx"] + + def process(self, instance): + invalid = [] + skeleton_mesh_data = instance.data("skeleton_mesh", []) + if skeleton_mesh_data: + invalid = self.get_top_hierarchy(skeleton_mesh_data) + if invalid: + raise PublishValidationError( + "The skeletonMesh_SET includes the object which " + "is not at the top hierarchy: {}".format(invalid)) + + def get_top_hierarchy(self, targets): + targets = cmds.ls(targets, long=True) # ensure long names + non_top_hierarchy_list = [ + target for target in targets if target.count("|") > 2 + ] + return non_top_hierarchy_list 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 5ba256f9f5..58fa9d02bd 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -69,11 +69,8 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, invalid = [] - project_settings = get_project_settings( - legacy_io.Session["AVALON_PROJECT"] - ) collision_prefixes = ( - project_settings + instance.context.data["project_settings"] ["maya"] ["create"] ["CreateUnrealStaticMesh"] diff --git a/openpype/hosts/nuke/api/__init__.py b/openpype/hosts/nuke/api/__init__.py index 1af5ff365d..a01f5bda0a 100644 --- a/openpype/hosts/nuke/api/__init__.py +++ b/openpype/hosts/nuke/api/__init__.py @@ -50,6 +50,8 @@ from .utils import ( get_colorspace_list ) +from .actions import SelectInvalidAction + __all__ = ( "file_extensions", "has_unsaved_changes", @@ -92,5 +94,7 @@ __all__ = ( "create_write_node", "colorspace_exists_on_node", - "get_colorspace_list" + "get_colorspace_list", + + "SelectInvalidAction", ) diff --git a/openpype/hosts/nuke/api/actions.py b/openpype/hosts/nuke/api/actions.py index c955a85acc..ca3c8393ed 100644 --- a/openpype/hosts/nuke/api/actions.py +++ b/openpype/hosts/nuke/api/actions.py @@ -20,33 +20,31 @@ class SelectInvalidAction(pyblish.api.Action): def process(self, context, plugin): - try: - import nuke - except ImportError: - raise ImportError("Current host is not Nuke") - - errored_instances = get_errored_instances_from_context(context, - plugin=plugin) + # Get the errored instances for the plug-in + errored_instances = get_errored_instances_from_context( + context, plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid nodes..") - invalid = list() + invalid_nodes = set() for instance in errored_instances: - invalid_nodes = plugin.get_invalid(instance) + invalid = plugin.get_invalid(instance) - if invalid_nodes: - if isinstance(invalid_nodes, (list, tuple)): - invalid.append(invalid_nodes[0]) - else: - self.log.warning("Plug-in returned to be invalid, " - "but has no selectable nodes.") + if not invalid: + continue - # Ensure unique (process each node only once) - invalid = list(set(invalid)) + select_node = instance.data.get("transientData", {}).get("node") + if not select_node: + raise RuntimeError( + "No transientData['node'] found on instance: {}".format( + instance) + ) - if invalid: - self.log.info("Selecting invalid nodes: {}".format(invalid)) + invalid_nodes.add(select_node) + + if invalid_nodes: + self.log.info("Selecting invalid nodes: {}".format(invalid_nodes)) reset_selection() - select_nodes(invalid) + select_nodes(list(invalid_nodes)) else: self.log.info("No invalid nodes found.") diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 41e6a27cef..390545b806 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2316,27 +2316,53 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. ''' Adds correct colorspace to write node dict ''' - for node in nuke.allNodes(filter="Group"): + for node in nuke.allNodes(filter="Group", group=self._root_node): + log.info("Setting colorspace to `{}`".format(node.name())) # get data from avalon knob avalon_knob_data = read_avalon_data(node) + node_data = get_node_data(node, INSTANCE_DATA_KNOB) - if avalon_knob_data.get("id") != "pyblish.avalon.instance": + if ( + # backward compatibility + # TODO: remove this once old avalon data api will be removed + avalon_knob_data + and avalon_knob_data.get("id") != "pyblish.avalon.instance" + ): + continue + elif ( + node_data + and node_data.get("id") != "pyblish.avalon.instance" + ): continue - if "creator" not in avalon_knob_data: + if ( + # backward compatibility + # TODO: remove this once old avalon data api will be removed + avalon_knob_data + and "creator" not in avalon_knob_data + ): + continue + elif ( + node_data + and "creator_identifier" not in node_data + ): continue - # establish families - families = [avalon_knob_data["family"]] - if avalon_knob_data.get("families"): - families.append(avalon_knob_data.get("families")) + nuke_imageio_writes = None + if avalon_knob_data: + # establish families + families = [avalon_knob_data["family"]] + if avalon_knob_data.get("families"): + families.append(avalon_knob_data.get("families")) - nuke_imageio_writes = get_imageio_node_setting( - node_class=avalon_knob_data["families"], - plugin_name=avalon_knob_data["creator"], - subset=avalon_knob_data["subset"] - ) + nuke_imageio_writes = get_imageio_node_setting( + node_class=avalon_knob_data["families"], + plugin_name=avalon_knob_data["creator"], + subset=avalon_knob_data["subset"] + ) + elif node_data: + nuke_imageio_writes = get_write_node_template_attr(node) log.debug("nuke_imageio_writes: `{}`".format(nuke_imageio_writes)) @@ -3397,3 +3423,27 @@ def create_viewer_profile_string(viewer, display=None, path_like=False): if path_like: return "{}/{}".format(display, viewer) return "{} ({})".format(viewer, display) + + +def get_filenames_without_hash(filename, frame_start, frame_end): + """Get filenames without frame hash + i.e. "renderCompositingMain.baking.0001.exr" + + Args: + filename (str): filename with frame hash + frame_start (str): start of the frame + frame_end (str): end of the frame + + Returns: + list: filename per frame of the sequence + """ + filenames = [] + for frame in range(int(frame_start), (int(frame_end) + 1)): + if "#" in filename: + # use regex to convert #### to {:0>4} + def replace(match): + return "{{:0>{}}}".format(len(match.group())) + filename_without_hashes = re.sub("#+", replace, filename) + new_filename = filename_without_hashes.format(frame) + filenames.append(new_filename) + return filenames diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index a0e1525cd0..c39e3c339d 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -21,6 +21,9 @@ from openpype.pipeline import ( CreatedInstance, get_current_task_name ) +from openpype.lib.transcoding import ( + VIDEO_EXTENSIONS +) from .lib import ( INSTANCE_DATA_KNOB, Knobby, @@ -35,7 +38,8 @@ from .lib import ( get_node_data, get_view_process_node, get_viewer_config_from_string, - deprecated + deprecated, + get_filenames_without_hash ) from .pipeline import ( list_instances, @@ -580,18 +584,25 @@ class ExporterReview(object): def get_file_info(self): if self.collection: # get path - self.fname = os.path.basename(self.collection.format( - "{head}{padding}{tail}")) + self.fname = os.path.basename( + self.collection.format("{head}{padding}{tail}") + ) self.fhead = self.collection.format("{head}") # get first and last frame self.first_frame = min(self.collection.indexes) self.last_frame = max(self.collection.indexes) + + # make sure slate frame is not included + frame_start_handle = self.instance.data["frameStartHandle"] + if frame_start_handle > self.first_frame: + self.first_frame = frame_start_handle + else: self.fname = os.path.basename(self.path_in) self.fhead = os.path.splitext(self.fname)[0] + "." - self.first_frame = self.instance.data.get("frameStartHandle", None) - self.last_frame = self.instance.data.get("frameEndHandle", None) + self.first_frame = self.instance.data["frameStartHandle"] + self.last_frame = self.instance.data["frameEndHandle"] if "#" in self.fhead: self.fhead = self.fhead.replace("#", "")[:-1] @@ -627,6 +638,10 @@ class ExporterReview(object): "frameStart": self.first_frame, "frameEnd": self.last_frame, }) + if ".{}".format(self.ext) not in VIDEO_EXTENSIONS: + filenames = get_filenames_without_hash( + self.file, self.first_frame, self.last_frame) + repre["files"] = filenames if self.multiple_presets: repre["outputName"] = self.name @@ -800,7 +815,20 @@ class ExporterReviewMov(ExporterReview): self.log.info("File info was set...") - self.file = self.fhead + self.name + ".{}".format(self.ext) + if ".{}".format(self.ext) in VIDEO_EXTENSIONS: + self.file = "{}{}.{}".format( + self.fhead, self.name, self.ext) + else: + # Output is image (or image sequence) + # When the file is an image it's possible it + # has extra information after the `fhead` that + # we want to preserve, e.g. like frame numbers + # or frames hashes like `####` + filename_no_ext = os.path.splitext( + os.path.basename(self.path_in))[0] + after_head = filename_no_ext[len(self.fhead):] + self.file = "{}{}.{}.{}".format( + self.fhead, self.name, after_head, self.ext) self.path = os.path.join( self.staging_dir, self.file).replace("\\", "/") @@ -869,6 +897,11 @@ class ExporterReviewMov(ExporterReview): r_node["origlast"].setValue(self.last_frame) r_node["colorspace"].setValue(self.write_colorspace) + # do not rely on defaults, set explicitly + # to be sure it is set correctly + r_node["frame_mode"].setValue("expression") + r_node["frame"].setValue("") + if read_raw: r_node["raw"].setValue(1) @@ -921,7 +954,6 @@ class ExporterReviewMov(ExporterReview): self.log.debug("Path: {}".format(self.path)) write_node["file"].setValue(str(self.path)) write_node["file_type"].setValue(str(self.ext)) - # Knobs `meta_codec` and `mov64_codec` are not available on centos. # TODO shouldn't this come from settings on outputs? try: diff --git a/openpype/hosts/nuke/plugins/load/actions.py b/openpype/hosts/nuke/plugins/load/actions.py index 3227a7ed98..635318f53d 100644 --- a/openpype/hosts/nuke/plugins/load/actions.py +++ b/openpype/hosts/nuke/plugins/load/actions.py @@ -17,7 +17,7 @@ class SetFrameRangeLoader(load.LoaderPlugin): "yeticache", "pointcache"] representations = ["*"] - extension = {"*"} + extensions = {"*"} label = "Set frame range" order = 11 diff --git a/openpype/hosts/nuke/plugins/load/load_backdrop.py b/openpype/hosts/nuke/plugins/load/load_backdrop.py index fe82d70b5e..0cbd380697 100644 --- a/openpype/hosts/nuke/plugins/load/load_backdrop.py +++ b/openpype/hosts/nuke/plugins/load/load_backdrop.py @@ -27,7 +27,7 @@ class LoadBackdropNodes(load.LoaderPlugin): families = ["workfile", "nukenodes"] representations = ["*"] - extension = {"nk"} + extensions = {"nk"} label = "Import Nuke Nodes" order = 0 diff --git a/openpype/hosts/nuke/plugins/load/load_camera_abc.py b/openpype/hosts/nuke/plugins/load/load_camera_abc.py index 2939ceebae..e245b0cb5e 100644 --- a/openpype/hosts/nuke/plugins/load/load_camera_abc.py +++ b/openpype/hosts/nuke/plugins/load/load_camera_abc.py @@ -26,7 +26,7 @@ class AlembicCameraLoader(load.LoaderPlugin): families = ["camera"] representations = ["*"] - extension = {"abc"} + extensions = {"abc"} label = "Load Alembic Camera" icon = "camera" diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index 89597e76cc..cacc00854e 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -24,7 +24,7 @@ class LoadEffects(load.LoaderPlugin): families = ["effect"] representations = ["*"] - extension = {"json"} + extensions = {"json"} label = "Load Effects - nodes" order = 0 diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index efe67be4aa..bdf3cd6965 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -25,7 +25,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin): families = ["effect"] representations = ["*"] - extension = {"json"} + extensions = {"json"} label = "Load Effects - Input Process" order = 0 diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py index 6b848ee276..ede05c422b 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo.py @@ -26,7 +26,7 @@ class LoadGizmo(load.LoaderPlugin): families = ["gizmo"] representations = ["*"] - extension = {"gizmo"} + extensions = {"gizmo"} label = "Load Gizmo" order = 0 diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py index a8e1218cbe..d567aaf7b0 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py @@ -28,7 +28,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin): families = ["gizmo"] representations = ["*"] - extension = {"gizmo"} + extensions = {"gizmo"} label = "Load Gizmo - Input Process" order = 0 diff --git a/openpype/hosts/nuke/plugins/load/load_matchmove.py b/openpype/hosts/nuke/plugins/load/load_matchmove.py index f942422c00..14ddf20dc3 100644 --- a/openpype/hosts/nuke/plugins/load/load_matchmove.py +++ b/openpype/hosts/nuke/plugins/load/load_matchmove.py @@ -9,7 +9,7 @@ class MatchmoveLoader(load.LoaderPlugin): families = ["matchmove"] representations = ["*"] - extension = {"py"} + extensions = {"py"} defaults = ["Camera", "Object"] diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index 0bdcd93dff..b9b8a0f4c0 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -24,7 +24,7 @@ class AlembicModelLoader(load.LoaderPlugin): families = ["model", "pointcache", "animation"] representations = ["*"] - extension = {"abc"} + extensions = {"abc"} label = "Load Alembic" icon = "cube" diff --git a/openpype/hosts/nuke/plugins/load/load_script_precomp.py b/openpype/hosts/nuke/plugins/load/load_script_precomp.py index 48d4a0900a..d5f9d24765 100644 --- a/openpype/hosts/nuke/plugins/load/load_script_precomp.py +++ b/openpype/hosts/nuke/plugins/load/load_script_precomp.py @@ -22,7 +22,7 @@ class LinkAsGroup(load.LoaderPlugin): families = ["workfile", "nukenodes"] representations = ["*"] - extension = {"nk"} + extensions = {"nk"} label = "Load Precomp" order = 0 diff --git a/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py b/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py index b0f69e8ab8..449a1cc935 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py +++ b/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py @@ -2,7 +2,7 @@ import nuke import pyblish.api -class CollectNukeInstanceData(pyblish.api.InstancePlugin): +class CollectInstanceData(pyblish.api.InstancePlugin): """Collect Nuke instance data """ diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py similarity index 83% rename from openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py rename to openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py index 956d1a54a3..9730e3b61f 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py @@ -8,15 +8,16 @@ from openpype.hosts.nuke.api import plugin from openpype.hosts.nuke.api.lib import maintained_selection -class ExtractReviewDataMov(publish.Extractor): - """Extracts movie and thumbnail with baked in luts +class ExtractReviewIntermediates(publish.Extractor): + """Extracting intermediate videos or sequences with + thumbnail for transcoding. must be run after extract_render_local.py """ order = pyblish.api.ExtractorOrder + 0.01 - label = "Extract Review Data Mov" + label = "Extract Review Intermediates" families = ["review"] hosts = ["nuke"] @@ -25,6 +26,24 @@ class ExtractReviewDataMov(publish.Extractor): viewer_lut_raw = None outputs = {} + @classmethod + def apply_settings(cls, project_settings): + """Apply the settings from the deprecated + ExtractReviewDataMov plugin for backwards compatibility + """ + nuke_publish = project_settings["nuke"]["publish"] + deprecated_setting = nuke_publish["ExtractReviewDataMov"] + current_setting = nuke_publish.get("ExtractReviewIntermediates") + if deprecated_setting["enabled"]: + # Use deprecated settings if they are still enabled + cls.viewer_lut_raw = deprecated_setting["viewer_lut_raw"] + cls.outputs = deprecated_setting["outputs"] + elif current_setting is None: + pass + elif current_setting["enabled"]: + cls.viewer_lut_raw = current_setting["viewer_lut_raw"] + cls.outputs = current_setting["outputs"] + def process(self, instance): families = set(instance.data["families"]) diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index b20df4ffe2..46288db743 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -8,6 +8,7 @@ from openpype.hosts.nuke import api as napi from openpype.hosts.nuke.api.lib import set_node_knobs_from_settings +# Python 2/3 compatibility if sys.version_info[0] >= 3: unicode = str @@ -45,11 +46,12 @@ class ExtractThumbnail(publish.Extractor): for o_name, o_data in instance.data["bakePresets"].items(): self.render_thumbnail(instance, o_name, **o_data) else: - viewer_process_swithes = { + viewer_process_switches = { "bake_viewer_process": True, "bake_viewer_input_process": True } - self.render_thumbnail(instance, None, **viewer_process_swithes) + self.render_thumbnail( + instance, None, **viewer_process_switches) def render_thumbnail(self, instance, output_name=None, **kwargs): first_frame = instance.data["frameStartHandle"] @@ -61,8 +63,6 @@ class ExtractThumbnail(publish.Extractor): # solve output name if any is set output_name = output_name or "" - if output_name: - output_name = "_" + output_name bake_viewer_process = kwargs["bake_viewer_process"] bake_viewer_input_process_node = kwargs[ @@ -166,26 +166,42 @@ class ExtractThumbnail(publish.Extractor): previous_node = dag_node temporary_nodes.append(dag_node) + thumb_name = "thumbnail" + # only add output name and + # if there are more than one bake preset + if ( + output_name + and len(instance.data.get("bakePresets", {}).keys()) > 1 + ): + thumb_name = "{}_{}".format(output_name, thumb_name) + # create write node write_node = nuke.createNode("Write") - file = fhead[:-1] + output_name + ".jpg" - name = "thumbnail" - path = os.path.join(staging_dir, file).replace("\\", "/") - instance.data["thumbnail"] = path - write_node["file"].setValue(path) + file = fhead[:-1] + thumb_name + ".jpg" + thumb_path = os.path.join(staging_dir, file).replace("\\", "/") + + # add thumbnail to cleanup + instance.context.data["cleanupFullPaths"].append(thumb_path) + + # make sure only one thumbnail path is set + # and it is existing file + instance_thumb_path = instance.data.get("thumbnailPath") + if not instance_thumb_path or not os.path.isfile(instance_thumb_path): + instance.data["thumbnailPath"] = thumb_path + + write_node["file"].setValue(thumb_path) write_node["file_type"].setValue("jpg") write_node["raw"].setValue(1) write_node.setInput(0, previous_node) temporary_nodes.append(write_node) - tags = ["thumbnail", "publish_on_farm"] repre = { - 'name': name, + 'name': thumb_name, 'ext': "jpg", - "outputName": "thumb", + "outputName": thumb_name, 'files': file, "stagingDir": staging_dir, - "tags": tags + "tags": ["thumbnail", "publish_on_farm", "delete"] } instance.data["representations"].append(repre) diff --git a/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml b/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml new file mode 100644 index 0000000000..d9394ae510 --- /dev/null +++ b/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml @@ -0,0 +1,31 @@ + + + + Shot/Asset name + +## Publishing to a different asset context + +There are publish instances present which are publishing into a different asset than your current context. + +Usually this is not what you want but there can be cases where you might want to publish into another asset/shot or task. + +If that's the case you can disable the validation on the instance to ignore it. + +The wrong node's name is: \`{node_name}\` + +### Correct context keys and values: + +\`{correct_values}\` + +### Wrong keys and values: + +\`{wrong_values}\`. + + +## How to repair? + +1. Use \"Repair\" button. +2. Hit Reload button on the publisher. + + + diff --git a/openpype/hosts/nuke/plugins/publish/help/validate_asset_name.xml b/openpype/hosts/nuke/plugins/publish/help/validate_asset_name.xml deleted file mode 100644 index 0422917e9c..0000000000 --- a/openpype/hosts/nuke/plugins/publish/help/validate_asset_name.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - Shot/Asset name - -## Invalid Shot/Asset name in subset - -Following Node with name `{node_name}`: -Is in context of `{correct_name}` but Node _asset_ knob is set as `{wrong_name}`. - -### How to repair? - -1. Either use Repair or Select button. -2. If you chose Select then rename asset knob to correct name. -3. Hit Reload button on the publisher. - - - \ No newline at end of file diff --git a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py new file mode 100644 index 0000000000..ab62daeaeb --- /dev/null +++ b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +"""Validate if instance asset is the same as context asset.""" +from __future__ import absolute_import + +import pyblish.api + +from openpype.pipeline.publish import ( + RepairAction, + ValidateContentsOrder, + PublishXmlValidationError, + OptionalPyblishPluginMixin +) +from openpype.hosts.nuke.api import SelectInvalidAction + + +class ValidateCorrectAssetContext( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin +): + """Validator to check if instance asset context match context asset. + + When working in per-shot style you always publish data in context of + current asset (shot). This validator checks if this is so. It is optional + so it can be disabled when needed. + + Checking `asset` and `task` keys. + """ + order = ValidateContentsOrder + label = "Validate asset context" + hosts = ["nuke"] + actions = [ + RepairAction, + SelectInvalidAction + ] + optional = True + + @classmethod + def apply_settings(cls, project_settings): + """Apply deprecated settings from project settings. + """ + nuke_publish = project_settings["nuke"]["publish"] + if "ValidateCorrectAssetName" in nuke_publish: + settings = nuke_publish["ValidateCorrectAssetName"] + else: + settings = nuke_publish["ValidateCorrectAssetContext"] + + cls.enabled = settings["enabled"] + cls.optional = settings["optional"] + cls.active = settings["active"] + + def process(self, instance): + if not self.is_active(instance.data): + return + + invalid_keys = self.get_invalid(instance) + + if not invalid_keys: + return + + message_values = { + "node_name": instance.data["transientData"]["node"].name(), + "correct_values": ", ".join([ + "{} > {}".format(_key, instance.context.data[_key]) + for _key in invalid_keys + ]), + "wrong_values": ", ".join([ + "{} > {}".format(_key, instance.data.get(_key)) + for _key in invalid_keys + ]) + } + + msg = ( + "Instance `{node_name}` has wrong context keys:\n" + "Correct: `{correct_values}` | Wrong: `{wrong_values}`").format( + **message_values) + + self.log.debug(msg) + + raise PublishXmlValidationError( + self, msg, formatting_data=message_values + ) + + @classmethod + def get_invalid(cls, instance): + """Get invalid keys from instance data and context data.""" + + invalid_keys = [] + testing_keys = ["asset", "task"] + for _key in testing_keys: + if _key not in instance.data: + invalid_keys.append(_key) + continue + if instance.data[_key] != instance.context.data[_key]: + invalid_keys.append(_key) + + return invalid_keys + + @classmethod + def repair(cls, instance): + """Repair instance data with context data.""" + invalid_keys = cls.get_invalid(instance) + + create_context = instance.context.data["create_context"] + + instance_id = instance.data.get("instance_id") + created_instance = create_context.get_instance_by_id( + instance_id + ) + for _key in invalid_keys: + created_instance[_key] = instance.context.data[_key] + + create_context.save_changes() diff --git a/openpype/hosts/nuke/plugins/publish/validate_asset_name.py b/openpype/hosts/nuke/plugins/publish/validate_asset_name.py deleted file mode 100644 index df05f76a5b..0000000000 --- a/openpype/hosts/nuke/plugins/publish/validate_asset_name.py +++ /dev/null @@ -1,138 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validate if instance asset is the same as context asset.""" -from __future__ import absolute_import - -import pyblish.api - -import openpype.hosts.nuke.api.lib as nlib - -from openpype.pipeline.publish import ( - ValidateContentsOrder, - PublishXmlValidationError, - OptionalPyblishPluginMixin -) - -class SelectInvalidInstances(pyblish.api.Action): - """Select invalid instances in Outliner.""" - - label = "Select" - icon = "briefcase" - on = "failed" - - def process(self, context, plugin): - """Process invalid validators and select invalid instances.""" - # Get the errored instances - failed = [] - for result in context.data["results"]: - if ( - result["error"] is None - or result["instance"] is None - or result["instance"] in failed - or result["plugin"] != plugin - ): - continue - - failed.append(result["instance"]) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(failed, plugin) - - if instances: - self.deselect() - self.log.info( - "Selecting invalid nodes: %s" % ", ".join( - [str(x) for x in instances] - ) - ) - self.select(instances) - else: - self.log.info("No invalid nodes found.") - self.deselect() - - def select(self, instances): - for inst in instances: - if inst.data.get("transientData", {}).get("node"): - select_node = inst.data["transientData"]["node"] - select_node["selected"].setValue(True) - - def deselect(self): - nlib.reset_selection() - - -class RepairSelectInvalidInstances(pyblish.api.Action): - """Repair the instance asset.""" - - label = "Repair" - icon = "wrench" - on = "failed" - - def process(self, context, plugin): - # Get the errored instances - failed = [] - for result in context.data["results"]: - if ( - result["error"] is None - or result["instance"] is None - or result["instance"] in failed - or result["plugin"] != plugin - ): - continue - - failed.append(result["instance"]) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(failed, plugin) - self.log.debug(instances) - - context_asset = context.data["assetEntity"]["name"] - for instance in instances: - node = instance.data["transientData"]["node"] - node_data = nlib.get_node_data(node, nlib.INSTANCE_DATA_KNOB) - node_data["asset"] = context_asset - nlib.set_node_data(node, nlib.INSTANCE_DATA_KNOB, node_data) - - -class ValidateCorrectAssetName( - pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin -): - """Validator to check if instance asset match context asset. - - When working in per-shot style you always publish data in context of - current asset (shot). This validator checks if this is so. It is optional - so it can be disabled when needed. - - Action on this validator will select invalid instances in Outliner. - """ - order = ValidateContentsOrder - label = "Validate correct asset name" - hosts = ["nuke"] - actions = [ - SelectInvalidInstances, - RepairSelectInvalidInstances - ] - optional = True - - def process(self, instance): - if not self.is_active(instance.data): - return - - asset = instance.data.get("asset") - context_asset = instance.context.data["assetEntity"]["name"] - node = instance.data["transientData"]["node"] - - msg = ( - "Instance `{}` has wrong shot/asset name:\n" - "Correct: `{}` | Wrong: `{}`").format( - instance.name, asset, context_asset) - - self.log.debug(msg) - - if asset != context_asset: - raise PublishXmlValidationError( - self, msg, formatting_data={ - "node_name": node.name(), - "wrong_name": asset, - "correct_name": context_asset - } - ) diff --git a/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py b/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py index dbcd216a84..39114c80c8 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py +++ b/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py @@ -23,7 +23,7 @@ class ValidateOutputResolution( order = pyblish.api.ValidatorOrder optional = True families = ["render"] - label = "Write resolution" + label = "Validate Write resolution" hosts = ["nuke"] actions = [RepairAction] diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py index 2a925fbeff..9aae53e59d 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py @@ -82,12 +82,6 @@ class ValidateNukeWriteNode( correct_data = get_write_node_template_attr(write_group_node) check = [] - self.log.debug("__ write_node: {}".format( - write_node - )) - self.log.debug("__ correct_data: {}".format( - correct_data - )) # Collect key values of same type in a list. values_by_name = defaultdict(list) @@ -96,9 +90,6 @@ class ValidateNukeWriteNode( for knob_data in correct_data["knobs"]: knob_type = knob_data["type"] - self.log.debug("__ knob_type: {}".format( - knob_type - )) if ( knob_type == "__legacy__" @@ -134,9 +125,6 @@ class ValidateNukeWriteNode( fixed_values.append(value) - self.log.debug("__ key: {} | values: {}".format( - key, fixed_values - )) if ( node_value not in fixed_values and key != "file" @@ -144,8 +132,6 @@ class ValidateNukeWriteNode( ): check.append([key, value, write_node[key].value()]) - self.log.info(check) - if check: self._make_error(check) diff --git a/openpype/hosts/photoshop/lib.py b/openpype/hosts/photoshop/lib.py index ae7a33b7b6..9f603a70d2 100644 --- a/openpype/hosts/photoshop/lib.py +++ b/openpype/hosts/photoshop/lib.py @@ -1,5 +1,8 @@ +import re + import openpype.hosts.photoshop.api as api from openpype.client import get_asset_by_name +from openpype.lib import prepare_template_data from openpype.pipeline import ( AutoCreator, CreatedInstance @@ -78,3 +81,17 @@ class PSAutoCreator(AutoCreator): existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name + + +def clean_subset_name(subset_name): + """Clean all variants leftover {layer} from subset name.""" + dynamic_data = prepare_template_data({"layer": "{layer}"}) + for value in dynamic_data.values(): + if value in subset_name: + subset_name = (subset_name.replace(value, "") + .replace("__", "_") + .replace("..", ".")) + # clean trailing separator as Main_ + pattern = r'[\W_]+$' + replacement = '' + return re.sub(pattern, replacement, subset_name) diff --git a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py index e4229788bd..afde77fdb4 100644 --- a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py @@ -2,7 +2,7 @@ from openpype.pipeline import CreatedInstance from openpype.lib import BoolDef import openpype.hosts.photoshop.api as api -from openpype.hosts.photoshop.lib import PSAutoCreator +from openpype.hosts.photoshop.lib import PSAutoCreator, clean_subset_name from openpype.pipeline.create import get_subset_name from openpype.lib import prepare_template_data from openpype.client import get_asset_by_name @@ -129,14 +129,4 @@ class AutoImageCreator(PSAutoCreator): self.family, variant, task_name, asset_doc, project_name, host_name, dynamic_data=dynamic_data ) - return self._clean_subset_name(subset_name) - - def _clean_subset_name(self, subset_name): - """Clean all variants leftover {layer} from subset name.""" - dynamic_data = prepare_template_data({"layer": "{layer}"}) - for value in dynamic_data.values(): - if value in subset_name: - return (subset_name.replace(value, "") - .replace("__", "_") - .replace("..", ".")) - return subset_name + return clean_subset_name(subset_name) diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index af20d456e0..4f2e90886a 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -10,6 +10,7 @@ from openpype.pipeline import ( from openpype.lib import prepare_template_data from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS from openpype.hosts.photoshop.api.pipeline import cache_and_get_instances +from openpype.hosts.photoshop.lib import clean_subset_name class ImageCreator(Creator): @@ -88,6 +89,7 @@ class ImageCreator(Creator): layer_fill = prepare_template_data({"layer": layer_name}) subset_name = subset_name.format(**layer_fill) + subset_name = clean_subset_name(subset_name) if group.long_name: for directory in group.long_name[::-1]: @@ -184,7 +186,6 @@ class ImageCreator(Creator): self.mark_for_review = plugin_settings["mark_for_review"] self.enabled = plugin_settings["enabled"] - def get_detail_description(self): return """Creator for Image instances diff --git a/openpype/hosts/resolve/api/__init__.py b/openpype/hosts/resolve/api/__init__.py index 2b4546f8d6..dba275e6c4 100644 --- a/openpype/hosts/resolve/api/__init__.py +++ b/openpype/hosts/resolve/api/__init__.py @@ -6,13 +6,10 @@ from .utils import ( ) from .pipeline import ( - install, - uninstall, + ResolveHost, ls, containerise, update_container, - publish, - launch_workfiles_app, maintained_selection, remove_instance, list_instances @@ -76,14 +73,10 @@ __all__ = [ "bmdvf", # pipeline - "install", - "uninstall", + "ResolveHost", "ls", "containerise", "update_container", - "reload_pipeline", - "publish", - "launch_workfiles_app", "maintained_selection", "remove_instance", "list_instances", diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py index b3717e01ea..34a63eb89f 100644 --- a/openpype/hosts/resolve/api/menu.py +++ b/openpype/hosts/resolve/api/menu.py @@ -5,11 +5,6 @@ from qtpy import QtWidgets, QtCore from openpype.tools.utils import host_tools -from .pipeline import ( - publish, - launch_workfiles_app -) - def load_stylesheet(): path = os.path.join(os.path.dirname(__file__), "menu_style.qss") @@ -113,7 +108,7 @@ class OpenPypeMenu(QtWidgets.QWidget): def on_workfile_clicked(self): print("Clicked Workfile") - launch_workfiles_app() + host_tools.show_workfiles() def on_create_clicked(self): print("Clicked Create") @@ -121,7 +116,7 @@ class OpenPypeMenu(QtWidgets.QWidget): def on_publish_clicked(self): print("Clicked Publish") - publish(None) + host_tools.show_publish(parent=None) def on_load_clicked(self): print("Clicked Load") diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index 899cb825bb..05f556fa5b 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -12,14 +12,24 @@ from openpype.pipeline import ( schema, register_loader_plugin_path, register_creator_plugin_path, - deregister_loader_plugin_path, - deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) -from openpype.tools.utils import host_tools +from openpype.host import ( + HostBase, + IWorkfileHost, + ILoadHost +) from . import lib from .utils import get_resolve_module +from .workio import ( + open_file, + save_file, + file_extensions, + has_unsaved_changes, + work_root, + current_file +) log = Logger.get_logger(__name__) @@ -32,53 +42,56 @@ CREATE_PATH = os.path.join(PLUGINS_DIR, "create") AVALON_CONTAINERS = ":AVALON_CONTAINERS" -def install(): - """Install resolve-specific functionality of avalon-core. +class ResolveHost(HostBase, IWorkfileHost, ILoadHost): + name = "resolve" - This is where you install menus and register families, data - and loaders into resolve. + def install(self): + """Install resolve-specific functionality of avalon-core. - It is called automatically when installing via `api.install(resolve)`. + This is where you install menus and register families, data + and loaders into resolve. - See the Maya equivalent for inspiration on how to implement this. + It is called automatically when installing via `api.install(resolve)`. - """ + See the Maya equivalent for inspiration on how to implement this. - log.info("openpype.hosts.resolve installed") + """ - pyblish.register_host("resolve") - pyblish.register_plugin_path(PUBLISH_PATH) - log.info("Registering DaVinci Resovle plug-ins..") + log.info("openpype.hosts.resolve installed") - register_loader_plugin_path(LOAD_PATH) - register_creator_plugin_path(CREATE_PATH) + pyblish.register_host(self.name) + pyblish.register_plugin_path(PUBLISH_PATH) + print("Registering DaVinci Resolve plug-ins..") - # register callback for switching publishable - pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) + register_loader_plugin_path(LOAD_PATH) + register_creator_plugin_path(CREATE_PATH) - get_resolve_module() + # register callback for switching publishable + pyblish.register_callback("instanceToggled", + on_pyblish_instance_toggled) + get_resolve_module() -def uninstall(): - """Uninstall all that was installed + def open_workfile(self, filepath): + return open_file(filepath) - This is where you undo everything that was done in `install()`. - That means, removing menus, deregistering families and data - and everything. It should be as though `install()` was never run, - because odds are calling this function means the user is interested - in re-installing shortly afterwards. If, for example, he has been - modifying the menu or registered families. + def save_workfile(self, filepath=None): + return save_file(filepath) - """ - pyblish.deregister_host("resolve") - pyblish.deregister_plugin_path(PUBLISH_PATH) - log.info("Deregistering DaVinci Resovle plug-ins..") + def work_root(self, session): + return work_root(session) - deregister_loader_plugin_path(LOAD_PATH) - deregister_creator_plugin_path(CREATE_PATH) + def get_current_workfile(self): + return current_file() - # register callback for switching publishable - pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) + def workfile_has_unsaved_changes(self): + return has_unsaved_changes() + + def get_workfile_extensions(self): + return file_extensions() + + def get_containers(self): + return ls() def containerise(timeline_item, @@ -206,15 +219,6 @@ def update_container(timeline_item, data=None): return bool(lib.set_timeline_item_pype_tag(timeline_item, container)) -def launch_workfiles_app(*args): - host_tools.show_workfiles() - - -def publish(parent): - """Shorthand to publish from within host""" - return host_tools.show_publish() - - @contextlib.contextmanager def maintained_selection(): """Maintain selection during context diff --git a/openpype/hosts/resolve/api/utils.py b/openpype/hosts/resolve/api/utils.py index 871b3af38d..851851a3b3 100644 --- a/openpype/hosts/resolve/api/utils.py +++ b/openpype/hosts/resolve/api/utils.py @@ -17,7 +17,7 @@ def get_resolve_module(): # dont run if already loaded if api.bmdvr: log.info(("resolve module is assigned to " - f"`pype.hosts.resolve.api.bmdvr`: {api.bmdvr}")) + f"`openpype.hosts.resolve.api.bmdvr`: {api.bmdvr}")) return api.bmdvr try: """ @@ -41,6 +41,10 @@ def get_resolve_module(): ) elif sys.platform.startswith("linux"): expected_path = "/opt/resolve/libs/Fusion/Modules" + else: + raise NotImplementedError( + "Unsupported platform: {}".format(sys.platform) + ) # check if the default path has it... print(("Unable to find module DaVinciResolveScript from " @@ -74,6 +78,6 @@ def get_resolve_module(): api.bmdvr = bmdvr api.bmdvf = bmdvf log.info(("Assigning resolve module to " - f"`pype.hosts.resolve.api.bmdvr`: {api.bmdvr}")) + f"`openpype.hosts.resolve.api.bmdvr`: {api.bmdvr}")) log.info(("Assigning resolve module to " - f"`pype.hosts.resolve.api.bmdvf`: {api.bmdvf}")) + f"`openpype.hosts.resolve.api.bmdvf`: {api.bmdvf}")) diff --git a/openpype/hosts/resolve/startup.py b/openpype/hosts/resolve/startup.py index e807a48f5a..5ac3c99524 100644 --- a/openpype/hosts/resolve/startup.py +++ b/openpype/hosts/resolve/startup.py @@ -27,7 +27,8 @@ def ensure_installed_host(): if host: return host - install_host(openpype.hosts.resolve.api) + host = openpype.hosts.resolve.api.ResolveHost() + install_host(host) return registered_host() @@ -37,10 +38,10 @@ def launch_menu(): openpype.hosts.resolve.api.launch_pype_menu() -def open_file(path): +def open_workfile(path): # Avoid the need to "install" the host host = ensure_installed_host() - host.open_file(path) + host.open_workfile(path) def main(): @@ -49,7 +50,7 @@ def main(): if workfile_path and os.path.exists(workfile_path): log.info(f"Opening last workfile: {workfile_path}") - open_file(workfile_path) + open_workfile(workfile_path) else: log.info("No last workfile set to open. Skipping..") diff --git a/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py b/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py index 1087a7b7a0..4f14927074 100644 --- a/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py +++ b/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py @@ -8,12 +8,13 @@ log = Logger.get_logger(__name__) def main(env): - import openpype.hosts.resolve.api as bmdvr + from openpype.hosts.resolve.api import ResolveHost, launch_pype_menu # activate resolve from openpype - install_host(bmdvr) + host = ResolveHost() + install_host(host) - bmdvr.launch_pype_menu() + launch_pype_menu() if __name__ == "__main__": diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_missing_frame_range_asset_entity.py b/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py similarity index 73% rename from openpype/hosts/traypublisher/plugins/publish/collect_missing_frame_range_asset_entity.py rename to openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py index 72379ea4e1..e8a2cae16c 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_missing_frame_range_asset_entity.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py @@ -1,28 +1,21 @@ import pyblish.api -from openpype.pipeline import OptionalPyblishPluginMixin -class CollectMissingFrameDataFromAssetEntity( - pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin -): - """Collect Missing Frame Range data From Asset Entity +class CollectFrameDataFromAssetEntity(pyblish.api.InstancePlugin): + """Collect Frame Data From AssetEntity found in context Frame range data will only be collected if the keys are not yet collected for the instance. """ order = pyblish.api.CollectorOrder + 0.491 - label = "Collect Missing Frame Data From Asset Entity" + label = "Collect Missing Frame Data From Asset" families = ["plate", "pointcache", "vdbcache", "online", "render"] hosts = ["traypublisher"] - optional = True def process(self, instance): - if not self.is_active(instance.data): - return missing_keys = [] for key in ( "fps", diff --git a/openpype/plugins/publish/collect_sequence_frame_data.py b/openpype/hosts/traypublisher/plugins/publish/collect_sequence_frame_data.py similarity index 71% rename from openpype/plugins/publish/collect_sequence_frame_data.py rename to openpype/hosts/traypublisher/plugins/publish/collect_sequence_frame_data.py index 6c2bfbf358..db70d4fe0a 100644 --- a/openpype/plugins/publish/collect_sequence_frame_data.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_sequence_frame_data.py @@ -1,33 +1,47 @@ import pyblish.api import clique +from openpype.pipeline import OptionalPyblishPluginMixin + + +class CollectSequenceFrameData( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin +): + """Collect Original Sequence Frame Data -class CollectSequenceFrameData(pyblish.api.InstancePlugin): - """Collect Sequence Frame Data If the representation includes files with frame numbers, then set `frameStart` and `frameEnd` for the instance to the start and end frame respectively """ - order = pyblish.api.CollectorOrder + 0.2 - label = "Collect Sequence Frame Data" + order = pyblish.api.CollectorOrder + 0.4905 + label = "Collect Original Sequence Frame Data" families = ["plate", "pointcache", "vdbcache", "online", "render"] hosts = ["traypublisher"] + optional = True def process(self, instance): + if not self.is_active(instance.data): + return + frame_data = self.get_frame_data_from_repre_sequence(instance) + if not frame_data: # if no dict data skip collecting the frame range data return + for key, value in frame_data.items(): - if key not in instance.data: - instance.data[key] = value - self.log.debug(f"Collected Frame range data '{key}':{value} ") + instance.data[key] = value + self.log.debug(f"Collected Frame range data '{key}':{value} ") + def get_frame_data_from_repre_sequence(self, instance): repres = instance.data.get("representations") + asset_data = instance.data["assetEntity"]["data"] + if repres: first_repre = repres[0] if "ext" not in first_repre: @@ -36,7 +50,7 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): return files = first_repre["files"] - collections, remainder = clique.assemble(files) + collections, _ = clique.assemble(files) if not collections: # No sequences detected and we can't retrieve # frame range @@ -52,5 +66,5 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): "frameEnd": repres_frames[-1], "handleStart": 0, "handleEnd": 0, - "fps": instance.context.data["assetEntity"]["data"]["fps"] + "fps": asset_data["fps"] } diff --git a/openpype/hosts/unreal/plugins/load/load_yeticache.py b/openpype/hosts/unreal/plugins/load/load_yeticache.py new file mode 100644 index 0000000000..22f5029bac --- /dev/null +++ b/openpype/hosts/unreal/plugins/load/load_yeticache.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +"""Loader for Yeti Cache.""" +import os +import json + +from openpype.pipeline import ( + get_representation_path, + AYON_CONTAINER_ID +) +from openpype.hosts.unreal.api import plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa + + +class YetiLoader(plugin.Loader): + """Load Yeti Cache""" + + families = ["yeticacheUE"] + label = "Import Yeti" + representations = ["abc"] + icon = "pagelines" + color = "orange" + + @staticmethod + def get_task(filename, asset_dir, asset_name, replace): + task = unreal.AssetImportTask() + options = unreal.AbcImportSettings() + + task.set_editor_property('filename', filename) + task.set_editor_property('destination_path', asset_dir) + task.set_editor_property('destination_name', asset_name) + task.set_editor_property('replace_existing', replace) + task.set_editor_property('automated', True) + task.set_editor_property('save', True) + + task.options = options + + return task + + @staticmethod + def is_groom_module_active(): + """ + Check if Groom plugin is active. + + This is a workaround, because the Unreal python API don't have + any method to check if plugin is active. + """ + prj_file = unreal.Paths.get_project_file_path() + + with open(prj_file, "r") as fp: + data = json.load(fp) + + plugins = data.get("Plugins") + + if not plugins: + return False + + plugin_names = [p.get("Name") for p in plugins] + + return "HairStrands" in plugin_names + + 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 + + """ + # Check if Groom plugin is active + if not self.is_groom_module_active(): + raise RuntimeError("Groom plugin is not activated.") + + # Create directory for asset and Ayon container + root = "/Game/Ayon/Assets" + asset = context.get('asset').get('name') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{root}/{asset}/{name}", suffix="") + + unique_number = 1 + while unreal.EditorAssetLibrary.does_directory_exist( + f"{asset_dir}_{unique_number:02}" + ): + unique_number += 1 + + asset_dir = f"{asset_dir}_{unique_number:02}" + container_name = f"{container_name}_{unique_number:02}{suffix}" + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + unreal.EditorAssetLibrary.make_directory(asset_dir) + + path = self.filepath_from_context(context) + task = self.get_task(path, asset_dir, asset_name, False) + + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 + + # Create Asset Container + unreal_pipeline.create_container( + container=container_name, path=asset_dir) + + data = { + "schema": "ayon:container-2.0", + "id": AYON_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(f"{asset_dir}/{container_name}", data) + + asset_content = unreal.EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=True + ) + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + + return asset_content + + def update(self, container, representation): + name = container["asset_name"] + source_path = get_representation_path(representation) + destination_path = container["namespace"] + + task = self.get_task(source_path, destination_path, name, True) + + # do import fbx and replace existing data + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + + container_path = f'{container["namespace"]}/{container["objectName"]}' + # update metadata + unreal_pipeline.imprint( + container_path, + { + "representation": str(representation["_id"]), + "parent": str(representation["parent"]) + }) + + asset_content = unreal.EditorAssetLibrary.list_assets( + destination_path, recursive=True, include_folder=True + ) + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + + def remove(self, container): + path = container["namespace"] + parent_path = os.path.dirname(path) + + unreal.EditorAssetLibrary.delete_directory(path) + + asset_content = unreal.EditorAssetLibrary.list_assets( + parent_path, recursive=False + ) + + if len(asset_content) == 0: + unreal.EditorAssetLibrary.delete_directory(parent_path) diff --git a/openpype/modules/avalon_apps/avalon_app.py b/openpype/modules/avalon_apps/avalon_app.py index a0226ecc5c..57754793c4 100644 --- a/openpype/modules/avalon_apps/avalon_app.py +++ b/openpype/modules/avalon_apps/avalon_app.py @@ -1,5 +1,6 @@ import os +from openpype import AYON_SERVER_ENABLED from openpype.modules import OpenPypeModule, ITrayModule @@ -75,20 +76,11 @@ class AvalonModule(OpenPypeModule, ITrayModule): def show_library_loader(self): if self._library_loader_window is None: - from qtpy import QtCore - from openpype.tools.libraryloader import LibraryLoaderWindow from openpype.pipeline import install_openpype_plugins - - libraryloader = LibraryLoaderWindow( - show_projects=True, - show_libraries=True - ) - # Remove always on top flag for tray - window_flags = libraryloader.windowFlags() - if window_flags | QtCore.Qt.WindowStaysOnTopHint: - window_flags ^= QtCore.Qt.WindowStaysOnTopHint - libraryloader.setWindowFlags(window_flags) - self._library_loader_window = libraryloader + if AYON_SERVER_ENABLED: + self._init_ayon_loader() + else: + self._init_library_loader() install_openpype_plugins() @@ -106,3 +98,25 @@ class AvalonModule(OpenPypeModule, ITrayModule): if self.tray_initialized: from .rest_api import AvalonRestApiResource self.rest_api_obj = AvalonRestApiResource(self, server_manager) + + def _init_library_loader(self): + from qtpy import QtCore + from openpype.tools.libraryloader import LibraryLoaderWindow + + libraryloader = LibraryLoaderWindow( + show_projects=True, + show_libraries=True + ) + # Remove always on top flag for tray + window_flags = libraryloader.windowFlags() + if window_flags | QtCore.Qt.WindowStaysOnTopHint: + window_flags ^= QtCore.Qt.WindowStaysOnTopHint + libraryloader.setWindowFlags(window_flags) + self._library_loader_window = libraryloader + + def _init_ayon_loader(self): + from openpype.tools.ayon_loader.ui import LoaderWindow + + libraryloader = LoaderWindow() + + self._library_loader_window = libraryloader diff --git a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py new file mode 100644 index 0000000000..4a7497b075 --- /dev/null +++ b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +"""Submitting render job to Deadline.""" + +import os +import getpass +import attr +from datetime import datetime + +import bpy + +from openpype.lib import is_running_from_build +from openpype.pipeline import legacy_io +from openpype.pipeline.farm.tools import iter_expected_files +from openpype.tests.lib import is_in_tests + +from openpype_modules.deadline import abstract_submit_deadline +from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo + + +@attr.s +class BlenderPluginInfo(): + SceneFile = attr.ib(default=None) # Input + Version = attr.ib(default=None) # Mandatory for Deadline + SaveFile = attr.ib(default=True) + + +class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): + label = "Submit Render to Deadline" + hosts = ["blender"] + families = ["render.farm"] + + use_published = True + priority = 50 + chunk_size = 1 + jobInfo = {} + pluginInfo = {} + group = None + + def get_job_info(self): + job_info = DeadlineJobInfo(Plugin="Blender") + + job_info.update(self.jobInfo) + + instance = self._instance + context = instance.context + + # Always use the original work file name for the Job name even when + # rendering is done from the published Work File. The original work + # file name is clearer because it can also have subversion strings, + # etc. which are stripped for the published file. + src_filepath = context.data["currentFile"] + src_filename = os.path.basename(src_filepath) + + if is_in_tests(): + src_filename += datetime.now().strftime("%d%m%Y%H%M%S") + + job_info.Name = f"{src_filename} - {instance.name}" + job_info.BatchName = src_filename + instance.data.get("blenderRenderPlugin", "Blender") + job_info.UserName = context.data.get("deadlineUser", getpass.getuser()) + + # Deadline requires integers in frame range + frames = "{start}-{end}x{step}".format( + start=int(instance.data["frameStartHandle"]), + end=int(instance.data["frameEndHandle"]), + step=int(instance.data["byFrameStep"]), + ) + job_info.Frames = frames + + job_info.Pool = instance.data.get("primaryPool") + job_info.SecondaryPool = instance.data.get("secondaryPool") + job_info.Comment = context.data.get("comment") + job_info.Priority = instance.data.get("priority", self.priority) + + if self.group != "none" and self.group: + job_info.Group = self.group + + attr_values = self.get_attr_values_from_data(instance.data) + render_globals = instance.data.setdefault("renderGlobals", {}) + machine_list = attr_values.get("machineList", "") + if machine_list: + if attr_values.get("whitelist", True): + machine_list_key = "Whitelist" + else: + machine_list_key = "Blacklist" + render_globals[machine_list_key] = machine_list + + job_info.Priority = attr_values.get("priority") + job_info.ChunkSize = attr_values.get("chunkSize") + + # Add options from RenderGlobals + render_globals = instance.data.get("renderGlobals", {}) + job_info.update(render_globals) + + keys = [ + "FTRACK_API_KEY", + "FTRACK_API_USER", + "FTRACK_SERVER", + "OPENPYPE_SG_USER", + "AVALON_PROJECT", + "AVALON_ASSET", + "AVALON_TASK", + "AVALON_APP_NAME", + "OPENPYPE_DEV" + "IS_TEST" + ] + + # Add OpenPype version if we are running from build. + if is_running_from_build(): + keys.append("OPENPYPE_VERSION") + + # Add mongo url if it's enabled + if self._instance.context.data.get("deadlinePassMongoUrl"): + keys.append("OPENPYPE_MONGO") + + environment = dict({key: os.environ[key] for key in keys + if key in os.environ}, **legacy_io.Session) + + for key in keys: + value = environment.get(key) + if not value: + continue + job_info.EnvironmentKeyValue[key] = value + + # to recognize job from PYPE for turning Event On/Off + job_info.add_render_job_env_var() + job_info.EnvironmentKeyValue["OPENPYPE_LOG_NO_COLORS"] = "1" + + # Adding file dependencies. + if self.asset_dependencies: + dependencies = instance.context.data["fileDependencies"] + for dependency in dependencies: + job_info.AssetDependency += dependency + + # Add list of expected files to job + # --------------------------------- + exp = instance.data.get("expectedFiles") + for filepath in iter_expected_files(exp): + job_info.OutputDirectory += os.path.dirname(filepath) + job_info.OutputFilename += os.path.basename(filepath) + + return job_info + + def get_plugin_info(self): + plugin_info = BlenderPluginInfo( + SceneFile=self.scene_path, + Version=bpy.app.version_string, + SaveFile=True, + ) + + plugin_payload = attr.asdict(plugin_info) + + # Patching with pluginInfo from settings + for key, value in self.pluginInfo.items(): + plugin_payload[key] = value + + return plugin_payload + + def process_submission(self): + instance = self._instance + + expected_files = instance.data["expectedFiles"] + if not expected_files: + raise RuntimeError("No Render Elements found!") + + first_file = next(iter_expected_files(expected_files)) + output_dir = os.path.dirname(first_file) + instance.data["outputDir"] = output_dir + instance.data["toBeRenderedOn"] = "deadline" + + payload = self.assemble_payload() + return self.submit(payload) + + def from_published_scene(self): + """ + This is needed to set the correct path for the json metadata. Because + the rendering path is set in the blend file during the collection, + and the path is adjusted to use the published scene, this ensures that + the metadata and the rendered files are in the same location. + """ + return super().from_published_scene(False) diff --git a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py index 70aa12956d..0b97582d2a 100644 --- a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py @@ -6,6 +6,7 @@ import requests import pyblish.api +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io from openpype.pipeline.publish import ( OpenPypePyblishPluginMixin @@ -34,6 +35,8 @@ class FusionSubmitDeadline( targets = ["local"] # presets + plugin = None + priority = 50 chunk_size = 1 concurrent_tasks = 1 @@ -173,7 +176,7 @@ class FusionSubmitDeadline( "SecondaryPool": instance.data.get("secondaryPool"), "Group": self.group, - "Plugin": "Fusion", + "Plugin": self.plugin, "Frames": "{start}-{end}".format( start=int(instance.data["frameStartHandle"]), end=int(instance.data["frameEndHandle"]) @@ -216,16 +219,29 @@ class FusionSubmitDeadline( # Include critical variables with submission keys = [ - # TODO: This won't work if the slaves don't have access to - # these paths, such as if slaves are running Linux and the - # submitter is on Windows. - "PYTHONPATH", - "OFX_PLUGIN_PATH", - "FUSION9_MasterPrefs" + "FTRACK_API_KEY", + "FTRACK_API_USER", + "FTRACK_SERVER", + "AVALON_PROJECT", + "AVALON_ASSET", + "AVALON_TASK", + "AVALON_APP_NAME", + "OPENPYPE_DEV", + "OPENPYPE_LOG_NO_COLORS", + "IS_TEST" ] environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) + # to recognize render jobs + if AYON_SERVER_ENABLED: + environment["AYON_BUNDLE_NAME"] = os.environ["AYON_BUNDLE_NAME"] + render_job_label = "AYON_RENDER_JOB" + else: + render_job_label = "OPENPYPE_RENDER_JOB" + + environment[render_job_label] = "1" + payload["JobInfo"].update({ "EnvironmentKeyValue%d" % index: "{key}={value}".format( key=key, diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 63c6e4a0c7..073da3019a 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -238,9 +238,10 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, plugin_data["redshift_SeparateAovFiles"] = instance.data.get( "separateAovFiles") if instance.data["cameras"]: - plugin_info["Camera0"] = None - plugin_info["Camera"] = instance.data["cameras"][0] - plugin_info["Camera1"] = instance.data["cameras"][0] + camera = instance.data["cameras"][0] + plugin_info["Camera0"] = camera + plugin_info["Camera"] = camera + plugin_info["Camera1"] = camera self.log.debug("plugin data:{}".format(plugin_data)) plugin_info.update(plugin_data) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 909975f7ab..6ed5819f2b 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -96,7 +96,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, targets = ["local"] hosts = ["fusion", "max", "maya", "nuke", "houdini", - "celaction", "aftereffects", "harmony"] + "celaction", "aftereffects", "harmony", "blender"] families = ["render.farm", "render.frames_farm", "prerender.farm", "prerender.frames_farm", @@ -107,6 +107,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "redshift_rop"] aov_filter = {"maya": [r".*([Bb]eauty).*"], + "blender": [r".*([Bb]eauty).*"], "aftereffects": [r".*"], # for everything from AE "harmony": [r".*"], # for everything from AE "celaction": [r".*"], diff --git a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py index a29acf9823..2c55e7c951 100644 --- a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py +++ b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py @@ -96,7 +96,7 @@ class AyonDeadlinePlugin(DeadlinePlugin): for path in exe_list.split(";"): if path.startswith("~"): path = os.path.expanduser(path) - expanded_paths.append(path) + expanded_paths.append(path) exe = FileUtils.SearchFileList(";".join(expanded_paths)) if exe == "": diff --git a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py b/openpype/modules/muster/plugins/publish/submit_maya_muster.py similarity index 99% rename from openpype/hosts/maya/plugins/publish/submit_maya_muster.py rename to openpype/modules/muster/plugins/publish/submit_maya_muster.py index c174fa7a33..5c95744876 100644 --- a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py +++ b/openpype/modules/muster/plugins/publish/submit_maya_muster.py @@ -25,6 +25,7 @@ def _get_template_id(renderer): :rtype: int """ + # TODO: Use settings from context? templates = get_system_settings()["modules"]["muster"]["templates_mapping"] if not templates: raise RuntimeError(("Muster template mapping missing in " diff --git a/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py index 047e35e3ac..bdb4b109a1 100644 --- a/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py +++ b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py @@ -1,5 +1,6 @@ import os import shutil +import filecmp from openpype.client.entities import get_representations from openpype.lib.applications import PreLaunchHook, LaunchTypes @@ -194,3 +195,69 @@ class CopyLastPublishedWorkfile(PreLaunchHook): self.data["last_workfile_path"] = local_workfile_path # Keep source filepath for further path conformation self.data["source_filepath"] = last_published_workfile_path + + # Get resources directory + resources_dir = os.path.join( + os.path.dirname(local_workfile_path), 'resources' + ) + # Make resource directory if it doesn't exist + if not os.path.exists(resources_dir): + os.mkdir(resources_dir) + + # Copy resources to the local resources directory + for file in workfile_representation['files']: + # Get resource main path + resource_main_path = anatomy.fill_root(file["path"]) + + # Get resource file basename + resource_basename = os.path.basename(resource_main_path) + + # Only copy if the resource file exists, and it's not the workfile + if ( + not os.path.exists(resource_main_path) + or resource_basename == os.path.basename( + last_published_workfile_path + ) + ): + continue + + # Get resource path in workfile folder + resource_work_path = os.path.join( + resources_dir, resource_basename + ) + + # Check if the resource file already exists in the resources folder + if os.path.exists(resource_work_path): + # Check if both files are the same + if filecmp.cmp(resource_main_path, resource_work_path): + self.log.warning( + 'Resource "{}" already exists.' + .format(resource_basename) + ) + continue + else: + # Add `.old` to existing resource path + resource_path_old = resource_work_path + '.old' + if os.path.exists(resource_work_path + '.old'): + for i in range(1, 100): + p = resource_path_old + '%02d' % i + if not os.path.exists(p): + # Rename existing resource file to + # `resource_name.old` + 2 digits + shutil.move(resource_work_path, p) + break + else: + self.log.warning( + 'There are a hundred old files for ' + 'resource "{}". ' + 'Perhaps is it time to clean up your ' + 'resources folder' + .format(resource_basename) + ) + continue + else: + # Rename existing resource file to `resource_name.old` + shutil.move(resource_work_path, resource_path_old) + + # Copy resource file to workfile resources folder + shutil.copy(resource_main_path, resources_dir) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 44e5cb6c47..2800050496 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -2,9 +2,12 @@ from copy import deepcopy import re import os import json -import platform import contextlib +import functools +import platform import tempfile +import warnings + from openpype import PACKAGE_DIR from openpype.settings import get_project_settings from openpype.lib import ( @@ -20,12 +23,60 @@ log = Logger.get_logger(__name__) class CachedData: - remapping = {} + remapping = None + has_compatible_ocio_package = None + config_version_data = {} + ocio_config_colorspaces = {} allowed_exts = { ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) } +class DeprecatedWarning(DeprecationWarning): + pass + + +def deprecated(new_destination): + """Mark functions as deprecated. + + It will result in a warning being emitted when the function is used. + """ + + func = None + if callable(new_destination): + func = new_destination + new_destination = None + + def _decorator(decorated_func): + if new_destination is None: + warning_message = ( + " Please check content of deprecated function to figure out" + " possible replacement." + ) + else: + warning_message = " Please replace your usage with '{}'.".format( + new_destination + ) + + @functools.wraps(decorated_func) + def wrapper(*args, **kwargs): + warnings.simplefilter("always", DeprecatedWarning) + warnings.warn( + ( + "Call to deprecated function '{}'" + "\nFunction was moved or removed.{}" + ).format(decorated_func.__name__, warning_message), + category=DeprecatedWarning, + stacklevel=4 + ) + return decorated_func(*args, **kwargs) + return wrapper + + if func is None: + return _decorator + return _decorator(func) + + @contextlib.contextmanager def _make_temp_json_file(): """Wrapping function for json temp file @@ -69,124 +120,264 @@ def get_ocio_config_script_path(): ) -def get_imageio_colorspace_from_filepath( - path, host_name, project_name, +def get_colorspace_name_from_filepath( + filepath, host_name, project_name, config_data=None, file_rules=None, project_settings=None, validate=True ): """Get colorspace name from filepath - ImageIO Settings file rules are tested for matching rule. - Args: - path (str): path string, file rule pattern is tested on it + filepath (str): path string, file rule pattern is tested on it host_name (str): host name project_name (str): project name - config_data (dict, optional): config path and template in dict. + config_data (Optional[dict]): config path and template in dict. Defaults to None. - file_rules (dict, optional): file rule data from settings. + file_rules (Optional[dict]): file rule data from settings. Defaults to None. - project_settings (dict, optional): project settings. Defaults to None. - validate (bool, optional): should resulting colorspace be validated - with config file? Defaults to True. + project_settings (Optional[dict]): project settings. Defaults to None. + validate (Optional[bool]): should resulting colorspace be validated + with config file? Defaults to True. Returns: str: name of colorspace """ - if not any([config_data, file_rules]): - project_settings = project_settings or get_project_settings( - project_name - ) - config_data = get_imageio_config( - project_name, host_name, project_settings) + project_settings, config_data, file_rules = _get_context_settings( + host_name, project_name, + config_data=config_data, file_rules=file_rules, + project_settings=project_settings + ) - # in case host color management is not enabled - if not config_data: - return None + if not config_data: + # in case global or host color management is not enabled + return None - file_rules = get_imageio_file_rules( - project_name, host_name, project_settings) + # use ImageIO file rules + colorspace_name = get_imageio_file_rules_colorspace_from_filepath( + filepath, host_name, project_name, + config_data=config_data, file_rules=file_rules, + project_settings=project_settings + ) - # match file rule from path - colorspace_name = None - for _frule_name, file_rule in file_rules.items(): - pattern = file_rule["pattern"] - extension = file_rule["ext"] - ext_match = re.match( - r".*(?=.{})".format(extension), path - ) - file_match = re.search( - pattern, path - ) + # try to get colorspace from OCIO v2 file rules + if ( + not colorspace_name + and compatibility_check_config_version(config_data["path"], major=2) + ): + colorspace_name = get_config_file_rules_colorspace_from_filepath( + config_data["path"], filepath) - if ext_match and file_match: - colorspace_name = file_rule["colorspace"] + # use parse colorspace from filepath as fallback + colorspace_name = colorspace_name or parse_colorspace_from_filepath( + filepath, config_path=config_data["path"] + ) if not colorspace_name: log.info("No imageio file rule matched input path: '{}'".format( - path + filepath )) return None # validate matching colorspace with config - if validate and config_data: + if validate: validate_imageio_colorspace_in_config( config_data["path"], colorspace_name) return colorspace_name -def parse_colorspace_from_filepath( - path, host_name, project_name, - config_data=None, +# TODO: remove this in future - backward compatibility +@deprecated("get_imageio_file_rules_colorspace_from_filepath") +def get_imageio_colorspace_from_filepath(*args, **kwargs): + return get_imageio_file_rules_colorspace_from_filepath(*args, **kwargs) + +# TODO: remove this in future - backward compatibility +@deprecated("get_imageio_file_rules_colorspace_from_filepath") +def get_colorspace_from_filepath(*args, **kwargs): + return get_imageio_file_rules_colorspace_from_filepath(*args, **kwargs) + + +def _get_context_settings( + host_name, project_name, + config_data=None, file_rules=None, project_settings=None +): + project_settings = project_settings or get_project_settings( + project_name + ) + + config_data = config_data or get_imageio_config( + project_name, host_name, project_settings) + + # in case host color management is not enabled + if not config_data: + return (None, None, None) + + file_rules = file_rules or get_imageio_file_rules( + project_name, host_name, project_settings) + + return project_settings, config_data, file_rules + + +def get_imageio_file_rules_colorspace_from_filepath( + filepath, host_name, project_name, + config_data=None, file_rules=None, + project_settings=None +): + """Get colorspace name from filepath + + ImageIO Settings file rules are tested for matching rule. + + Args: + filepath (str): path string, file rule pattern is tested on it + host_name (str): host name + project_name (str): project name + config_data (Optional[dict]): config path and template in dict. + Defaults to None. + file_rules (Optional[dict]): file rule data from settings. + Defaults to None. + project_settings (Optional[dict]): project settings. Defaults to None. + + Returns: + str: name of colorspace + """ + project_settings, config_data, file_rules = _get_context_settings( + host_name, project_name, + config_data=config_data, file_rules=file_rules, + project_settings=project_settings + ) + + if not config_data: + # in case global or host color management is not enabled + return None + + # match file rule from path + colorspace_name = None + for file_rule in file_rules.values(): + pattern = file_rule["pattern"] + extension = file_rule["ext"] + ext_match = re.match( + r".*(?=.{})".format(extension), filepath + ) + file_match = re.search( + pattern, filepath + ) + + if ext_match and file_match: + colorspace_name = file_rule["colorspace"] + + return colorspace_name + + +def get_config_file_rules_colorspace_from_filepath(config_path, filepath): + """Get colorspace from file path wrapper. + + Wrapper function for getting colorspace from file path + with use of OCIO v2 file-rules. + + Args: + config_path (str): path leading to config.ocio file + filepath (str): path leading to a file + + Returns: + Any[str, None]: matching colorspace name + """ + if not compatibility_check(): + # python environment is not compatible with PyOpenColorIO + # needs to be run in subprocess + result_data = _get_wrapped_with_subprocess( + "colorspace", "get_config_file_rules_colorspace_from_filepath", + config_path=config_path, + filepath=filepath + ) + if result_data: + return result_data[0] + + # TODO: refactor this so it is not imported but part of this file + from openpype.scripts.ocio_wrapper import _get_config_file_rules_colorspace_from_filepath # noqa: E501 + + result_data = _get_config_file_rules_colorspace_from_filepath( + config_path, filepath) + + if result_data: + return result_data[0] + + +def parse_colorspace_from_filepath( + filepath, colorspaces=None, config_path=None ): """Parse colorspace name from filepath An input path can have colorspace name used as part of name or as folder name. + Example: + >>> config_path = "path/to/config.ocio" + >>> colorspaces = get_ocio_config_colorspaces(config_path) + >>> colorspace = parse_colorspace_from_filepath( + "path/to/file/acescg/file.exr", + colorspaces=colorspaces + ) + >>> print(colorspace) + acescg + Args: - path (str): path string - host_name (str): host name - project_name (str): project name - config_data (dict, optional): config path and template in dict. - Defaults to None. - project_settings (dict, optional): project settings. Defaults to None. + filepath (str): path string + colorspaces (Optional[dict[str]]): list of colorspaces + config_path (Optional[str]): path to config.ocio file Returns: str: name of colorspace """ - if not config_data: - project_settings = project_settings or get_project_settings( - project_name + def _get_colorspace_match_regex(colorspaces): + """Return a regex pattern + + Allows to search a colorspace match in a filename + + Args: + colorspaces (list): List of colorspace names + + Returns: + re.Pattern: regex pattern + """ + pattern = "|".join( + # Allow to match spaces also as underscores because the + # integrator replaces spaces with underscores in filenames + re.escape(colorspace) for colorspace in + # Sort by longest first so the regex matches longer matches + # over smaller matches, e.g. matching 'Output - sRGB' over 'sRGB' + sorted(colorspaces, key=len, reverse=True) ) - config_data = get_imageio_config( - project_name, host_name, project_settings) + return re.compile(pattern) - config_path = config_data["path"] + if not colorspaces and not config_path: + raise ValueError( + "Must provide `config_path` if `colorspaces` is not provided." + ) - # match file rule from path - colorspace_name = None - colorspaces = get_ocio_config_colorspaces(config_path) - for colorspace_key in colorspaces: - # check underscored variant of colorspace name - # since we are reformatting it in integrate.py - if colorspace_key.replace(" ", "_") in path: - colorspace_name = colorspace_key - break - if colorspace_key in path: - colorspace_name = colorspace_key - break + colorspaces = colorspaces or get_ocio_config_colorspaces(config_path) + underscored_colorspaces = { + key.replace(" ", "_"): key for key in colorspaces + if " " in key + } - if not colorspace_name: - log.info("No matching colorspace in config '{}' for path: '{}'".format( - config_path, path - )) - return None + # match colorspace from filepath + regex_pattern = _get_colorspace_match_regex( + list(colorspaces) + list(underscored_colorspaces)) + match = regex_pattern.search(filepath) + colorspace = match.group(0) if match else None - return colorspace_name + if colorspace in underscored_colorspaces: + return underscored_colorspaces[colorspace] + + if colorspace: + return colorspace + + log.info("No matching colorspace in config '{}' for path: '{}'".format( + config_path, filepath + )) + return None def validate_imageio_colorspace_in_config(config_path, colorspace_name): @@ -211,49 +402,101 @@ def validate_imageio_colorspace_in_config(config_path, colorspace_name): return True +# TODO: remove this in future - backward compatibility +@deprecated("_get_wrapped_with_subprocess") def get_data_subprocess(config_path, data_type): - """Get data via subprocess + """[Deprecated] Get data via subprocess Wrapper for Python 2 hosts. Args: config_path (str): path leading to config.ocio file """ + return _get_wrapped_with_subprocess( + "config", data_type, in_path=config_path, + ) + + +def _get_wrapped_with_subprocess(command_group, command, **kwargs): + """Get data via subprocess + + Wrapper for Python 2 hosts. + + Args: + command_group (str): command group name + command (str): command name + **kwargs: command arguments + + Returns: + Any[dict, None]: data + """ with _make_temp_json_file() as tmp_json_path: # Prepare subprocess arguments args = [ "run", get_ocio_config_script_path(), - "config", data_type, - "--in_path", config_path, - "--out_path", tmp_json_path - + command_group, command ] + + for key_, value_ in kwargs.items(): + args.extend(("--{}".format(key_), value_)) + + args.append("--out_path") + args.append(tmp_json_path) + log.info("Executing: {}".format(" ".join(args))) - process_kwargs = { - "logger": log - } - - run_openpype_process(*args, **process_kwargs) + run_openpype_process(*args, logger=log) # return all colorspaces - return_json_data = open(tmp_json_path).read() - return json.loads(return_json_data) + with open(tmp_json_path, "r") as f_: + return json.load(f_) +# TODO: this should be part of ocio_wrapper.py def compatibility_check(): - """checking if user has a compatible PyOpenColorIO >= 2. + """Making sure PyOpenColorIO is importable""" + if CachedData.has_compatible_ocio_package is not None: + return CachedData.has_compatible_ocio_package - It's achieved by checking if PyOpenColorIO is importable - and calling any version 2 specific function - """ try: - import PyOpenColorIO + import PyOpenColorIO # noqa: F401 + CachedData.has_compatible_ocio_package = True + except ImportError: + CachedData.has_compatible_ocio_package = False - # ocio versions lower than 2 will raise AttributeError - PyOpenColorIO.GetVersion() - except (ImportError, AttributeError): + # compatible + return CachedData.has_compatible_ocio_package + + +# TODO: this should be part of ocio_wrapper.py +def compatibility_check_config_version(config_path, major=1, minor=None): + """Making sure PyOpenColorIO config version is compatible""" + + if not CachedData.config_version_data.get(config_path): + if compatibility_check(): + # TODO: refactor this so it is not imported but part of this file + from openpype.scripts.ocio_wrapper import _get_version_data + + CachedData.config_version_data[config_path] = \ + _get_version_data(config_path) + + else: + # python environment is not compatible with PyOpenColorIO + # needs to be run in subprocess + CachedData.config_version_data[config_path] = \ + _get_wrapped_with_subprocess( + "config", "get_version", config_path=config_path + ) + + # check major version + if CachedData.config_version_data[config_path]["major"] != major: return False + + # check minor version + if minor and CachedData.config_version_data[config_path]["minor"] != minor: + return False + + # compatible return True @@ -269,18 +512,28 @@ def get_ocio_config_colorspaces(config_path): Returns: dict: colorspace and family in couple """ - if not compatibility_check(): - # python environment is not compatible with PyOpenColorIO - # needs to be run in subprocess - return get_colorspace_data_subprocess(config_path) + if not CachedData.ocio_config_colorspaces.get(config_path): + if not compatibility_check(): + # python environment is not compatible with PyOpenColorIO + # needs to be run in subprocess + CachedData.ocio_config_colorspaces[config_path] = \ + _get_wrapped_with_subprocess( + "config", "get_colorspace", in_path=config_path + ) + else: + # TODO: refactor this so it is not imported but part of this file + from openpype.scripts.ocio_wrapper import _get_colorspace_data - from openpype.scripts.ocio_wrapper import _get_colorspace_data + CachedData.ocio_config_colorspaces[config_path] = \ + _get_colorspace_data(config_path) - return _get_colorspace_data(config_path) + return CachedData.ocio_config_colorspaces[config_path] +# TODO: remove this in future - backward compatibility +@deprecated("_get_wrapped_with_subprocess") def get_colorspace_data_subprocess(config_path): - """Get colorspace data via subprocess + """[Deprecated] Get colorspace data via subprocess Wrapper for Python 2 hosts. @@ -290,7 +543,9 @@ def get_colorspace_data_subprocess(config_path): Returns: dict: colorspace and family in couple """ - return get_data_subprocess(config_path, "get_colorspace") + return _get_wrapped_with_subprocess( + "config", "get_colorspace", in_path=config_path + ) def get_ocio_config_views(config_path): @@ -308,15 +563,20 @@ def get_ocio_config_views(config_path): if not compatibility_check(): # python environment is not compatible with PyOpenColorIO # needs to be run in subprocess - return get_views_data_subprocess(config_path) + return _get_wrapped_with_subprocess( + "config", "get_views", in_path=config_path + ) + # TODO: refactor this so it is not imported but part of this file from openpype.scripts.ocio_wrapper import _get_views_data return _get_views_data(config_path) +# TODO: remove this in future - backward compatibility +@deprecated("_get_wrapped_with_subprocess") def get_views_data_subprocess(config_path): - """Get viewers data via subprocess + """[Deprecated] Get viewers data via subprocess Wrapper for Python 2 hosts. @@ -326,7 +586,9 @@ def get_views_data_subprocess(config_path): Returns: dict: `display/viewer` and viewer data """ - return get_data_subprocess(config_path, "get_views") + return _get_wrapped_with_subprocess( + "config", "get_views", in_path=config_path + ) def get_imageio_config( diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index f567118062..13630ae7ca 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -25,7 +25,10 @@ from openpype.tests.lib import is_in_tests from .publish.lib import filter_pyblish_plugins from .anatomy import Anatomy -from .template_data import get_template_data_with_names +from .template_data import ( + get_template_data_with_names, + get_template_data +) from .workfile import ( get_workfile_template_key, get_custom_workfile_template_by_string_context, @@ -658,3 +661,70 @@ def get_process_id(): if _process_id is None: _process_id = str(uuid.uuid4()) return _process_id + + +def get_current_context_template_data(): + """Template data for template fill from current context + + Returns: + Dict[str, Any] of the following tokens and their values + Supported Tokens: + - Regular Tokens + - app + - user + - asset + - parent + - hierarchy + - folder[name] + - root[work, ...] + - studio[code, name] + - project[code, name] + - task[type, name, short] + + - Context Specific Tokens + - assetData[frameStart] + - assetData[frameEnd] + - assetData[handleStart] + - assetData[handleEnd] + - assetData[frameStartHandle] + - assetData[frameEndHandle] + - assetData[resolutionHeight] + - assetData[resolutionWidth] + + """ + + # pre-prepare get_template_data args + current_context = get_current_context() + project_name = current_context["project_name"] + asset_name = current_context["asset_name"] + anatomy = Anatomy(project_name) + + # prepare get_template_data args + project_doc = get_project(project_name) + asset_doc = get_asset_by_name(project_name, asset_name) + task_name = current_context["task_name"] + host_name = get_current_host_name() + + # get regular template data + template_data = get_template_data( + project_doc, asset_doc, task_name, host_name + ) + + template_data["root"] = anatomy.roots + + # get context specific vars + asset_data = asset_doc["data"].copy() + + # compute `frameStartHandle` and `frameEndHandle` + if "frameStart" in asset_data and "handleStart" in asset_data: + asset_data["frameStartHandle"] = \ + asset_data["frameStart"] - asset_data["handleStart"] + + if "frameEnd" in asset_data and "handleEnd" in asset_data: + asset_data["frameEndHandle"] = \ + asset_data["frameEnd"] + asset_data["handleEnd"] + + # add assetData + template_data["assetData"] = asset_data + + return template_data diff --git a/openpype/pipeline/create/subset_name.py b/openpype/pipeline/create/subset_name.py index 3f0692b46a..00025b19b8 100644 --- a/openpype/pipeline/create/subset_name.py +++ b/openpype/pipeline/create/subset_name.py @@ -14,6 +14,13 @@ class TaskNotSetError(KeyError): super(TaskNotSetError, self).__init__(msg) +class TemplateFillError(Exception): + def __init__(self, msg=None): + if not msg: + msg = "Creator's subset name template is missing key value." + super(TemplateFillError, self).__init__(msg) + + def get_subset_name_template( project_name, family, @@ -112,6 +119,10 @@ def get_subset_name( for project. Settings are queried if not passed. family_filter (Optional[str]): Use different family for subset template filtering. Value of 'family' is used when not passed. + + Raises: + TemplateFillError: If filled template contains placeholder key which is not + collected. """ if not family: @@ -154,4 +165,10 @@ def get_subset_name( for key, value in dynamic_data.items(): fill_pairs[key] = value - return template.format(**prepare_template_data(fill_pairs)) + try: + return template.format(**prepare_template_data(fill_pairs)) + except KeyError as exp: + raise TemplateFillError( + "Value for {} key is missing in template '{}'." + " Available values are {}".format(str(exp), template, fill_pairs) + ) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index fe3ab97de8..7ef3439dbd 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -107,17 +107,18 @@ def get_time_data_from_instance_or_context(instance): TimeData: dataclass holding time information. """ + context = instance.context return TimeData( - start=(instance.data.get("frameStart") or - instance.context.data.get("frameStart")), - end=(instance.data.get("frameEnd") or - instance.context.data.get("frameEnd")), - fps=(instance.data.get("fps") or - instance.context.data.get("fps")), - handle_start=(instance.data.get("handleStart") or - instance.context.data.get("handleStart")), # noqa: E501 - handle_end=(instance.data.get("handleEnd") or - instance.context.data.get("handleEnd")) + start=instance.data.get("frameStart", context.data.get("frameStart")), + end=instance.data.get("frameEnd", context.data.get("frameEnd")), + fps=instance.data.get("fps", context.data.get("fps")), + step=instance.data.get("byFrameStep", instance.data.get("step", 1)), + handle_start=instance.data.get( + "handleStart", context.data.get("handleStart") + ), + handle_end=instance.data.get( + "handleEnd", context.data.get("handleEnd") + ) ) diff --git a/openpype/pipeline/load/__init__.py b/openpype/pipeline/load/__init__.py index 7320a9f0e8..ca11b26211 100644 --- a/openpype/pipeline/load/__init__.py +++ b/openpype/pipeline/load/__init__.py @@ -32,6 +32,7 @@ from .utils import ( loaders_from_repre_context, loaders_from_representation, + filter_repre_contexts_by_loader, any_outdated_containers, get_outdated_containers, @@ -85,6 +86,7 @@ __all__ = ( "loaders_from_repre_context", "loaders_from_representation", + "filter_repre_contexts_by_loader", "any_outdated_containers", "get_outdated_containers", diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index b10d6032b3..c81aeff6bd 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -790,6 +790,24 @@ def loaders_from_repre_context(loaders, repre_context): ] +def filter_repre_contexts_by_loader(repre_contexts, loader): + """Filter representation contexts for loader. + + Args: + repre_contexts (list[dict[str, Ant]]): Representation context. + loader (LoaderPlugin): Loader plugin to filter contexts for. + + Returns: + list[dict[str, Any]]: Filtered representation contexts. + """ + + return [ + repre_context + for repre_context in repre_contexts + if is_compatible_loader(loader, repre_context) + ] + + def loaders_from_representation(loaders, representation): """Return all compatible loaders for a representation.""" diff --git a/openpype/pipeline/thumbnail.py b/openpype/pipeline/thumbnail.py index b2b3679450..63c55d0c19 100644 --- a/openpype/pipeline/thumbnail.py +++ b/openpype/pipeline/thumbnail.py @@ -166,8 +166,12 @@ class ServerThumbnailResolver(ThumbnailResolver): # This is new way how thumbnails can be received from server # - output is 'ThumbnailContent' object - if hasattr(ayon_api, "get_thumbnail_by_id"): - result = ayon_api.get_thumbnail_by_id(thumbnail_id) + # NOTE Use 'get_server_api_connection' because public function + # 'get_thumbnail_by_id' does not return output of 'ServerAPI' + # method. + con = ayon_api.get_server_api_connection() + if hasattr(con, "get_thumbnail_by_id"): + result = con.get_thumbnail_by_id(thumbnail_id) if result.is_valid: filepath = cache.store_thumbnail( project_name, @@ -178,7 +182,7 @@ class ServerThumbnailResolver(ThumbnailResolver): else: # Backwards compatibility for ayon api where 'get_thumbnail_by_id' # is not implemented and output is filepath - filepath = ayon_api.get_thumbnail( + filepath = con.get_thumbnail( project_name, entity_type, entity_id, thumbnail_id ) diff --git a/openpype/plugins/actions/open_file_explorer.py b/openpype/plugins/actions/open_file_explorer.py index e4fbd91143..1568c41fbd 100644 --- a/openpype/plugins/actions/open_file_explorer.py +++ b/openpype/plugins/actions/open_file_explorer.py @@ -83,10 +83,6 @@ class OpenTaskPath(LauncherAction): if os.path.exists(valid_workdir): return valid_workdir - # If task was selected, try to find asset path only to asset - if not task_name: - raise AssertionError("Folder does not exist.") - data.pop("task", None) workdir = anatomy.templates_obj["work"]["folder"].format(data) valid_workdir = self._find_first_filled_path(workdir) @@ -95,7 +91,7 @@ class OpenTaskPath(LauncherAction): valid_workdir = os.path.normpath(valid_workdir) if os.path.exists(valid_workdir): return valid_workdir - raise AssertionError("Folder does not exist.") + raise AssertionError("Folder does not exist yet.") @staticmethod def open_in_explorer(path): diff --git a/openpype/plugins/publish/collect_resources_path.py b/openpype/plugins/publish/collect_resources_path.py index f96dd0ae18..57a612c5ae 100644 --- a/openpype/plugins/publish/collect_resources_path.py +++ b/openpype/plugins/publish/collect_resources_path.py @@ -62,7 +62,8 @@ class CollectResourcesPath(pyblish.api.InstancePlugin): "effect", "staticMesh", "skeletalMesh", - "xgen" + "xgen", + "yeticacheUE" ] def process(self, instance): diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index 7e48155b9e..ce24dad1b5 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -139,7 +139,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "simpleUnrealTexture", "online", "uasset", - "blendScene" + "blendScene", + "yeticacheUE" ] default_template_name = "publish" diff --git a/openpype/plugins/publish/preintegrate_thumbnail_representation.py b/openpype/plugins/publish/preintegrate_thumbnail_representation.py index 1c95b82c97..77bf2edba5 100644 --- a/openpype/plugins/publish/preintegrate_thumbnail_representation.py +++ b/openpype/plugins/publish/preintegrate_thumbnail_representation.py @@ -29,13 +29,12 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin): if not repres: return - thumbnail_repre = None + thumbnail_repres = [] for repre in repres: - if repre["name"] == "thumbnail": - thumbnail_repre = repre - break + if "thumbnail" in repre.get("tags", []): + thumbnail_repres.append(repre) - if not thumbnail_repre: + if not thumbnail_repres: return family = instance.data["family"] @@ -60,14 +59,15 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin): if not found_profile: return - thumbnail_repre.setdefault("tags", []) + for thumbnail_repre in thumbnail_repres: + thumbnail_repre.setdefault("tags", []) - if not found_profile["integrate_thumbnail"]: - if "delete" not in thumbnail_repre["tags"]: - thumbnail_repre["tags"].append("delete") - else: - if "delete" in thumbnail_repre["tags"]: - thumbnail_repre["tags"].remove("delete") + if not found_profile["integrate_thumbnail"]: + if "delete" not in thumbnail_repre["tags"]: + thumbnail_repre["tags"].append("delete") + else: + if "delete" in thumbnail_repre["tags"]: + thumbnail_repre["tags"].remove("delete") - self.log.debug( - "Thumbnail repre tags {}".format(thumbnail_repre["tags"])) + self.log.debug( + "Thumbnail repre tags {}".format(thumbnail_repre["tags"])) diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 7f1c3b01e2..071ecfffd2 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -213,7 +213,8 @@ class PypeCommands: pass def run_tests(self, folder, mark, pyargs, - test_data_folder, persist, app_variant, timeout, setup_only): + test_data_folder, persist, app_variant, timeout, setup_only, + mongo_url): """ Runs tests from 'folder' @@ -226,6 +227,10 @@ class PypeCommands: end app_variant (str): variant (eg 2020 for AE), empty if use latest installed version + timeout (int): explicit timeout for single test + setup_only (bool): if only preparation steps should be + triggered, no tests (useful for debugging/development) + mongo_url (str): url to Openpype Mongo database """ print("run_tests") if folder: @@ -264,6 +269,9 @@ class PypeCommands: if setup_only: args.extend(["--setup_only", setup_only]) + if mongo_url: + args.extend(["--mongo_url", mongo_url]) + print("run_tests args: {}".format(args)) import pytest pytest.main(args) diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 40553d30f2..c362670126 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -27,7 +27,7 @@ import PyOpenColorIO as ocio @click.group() def main(): - pass + pass # noqa: WPS100 @main.group() @@ -37,7 +37,17 @@ def config(): Example of use: > pyton.exe ./ocio_wrapper.py config *args """ - pass + pass # noqa: WPS100 + + +@main.group() +def colorspace(): + """Colorspace related commands group + + Example of use: + > pyton.exe ./ocio_wrapper.py config *args + """ + pass # noqa: WPS100 @config.command( @@ -70,8 +80,8 @@ def get_colorspace(in_path, out_path): out_data = _get_colorspace_data(in_path) - with open(json_path, "w") as f: - json.dump(out_data, f) + with open(json_path, "w") as f_: + json.dump(out_data, f_) print(f"Colorspace data are saved to '{json_path}'") @@ -97,8 +107,8 @@ def _get_colorspace_data(config_path): config = ocio.Config().CreateFromFile(str(config_path)) return { - c.getName(): c.getFamily() - for c in config.getColorSpaces() + c_.getName(): c_.getFamily() + for c_ in config.getColorSpaces() } @@ -132,8 +142,8 @@ def get_views(in_path, out_path): out_data = _get_views_data(in_path) - with open(json_path, "w") as f: - json.dump(out_data, f) + with open(json_path, "w") as f_: + json.dump(out_data, f_) print(f"Viewer data are saved to '{json_path}'") @@ -157,7 +167,7 @@ def _get_views_data(config_path): config = ocio.Config().CreateFromFile(str(config_path)) - data = {} + data_ = {} for display in config.getDisplays(): for view in config.getViews(display): colorspace = config.getDisplayViewColorSpaceName(display, view) @@ -165,13 +175,148 @@ def _get_views_data(config_path): if colorspace == "": colorspace = display - data[f"{display}/{view}"] = { + data_[f"{display}/{view}"] = { "display": display, "view": view, "colorspace": colorspace } - return data + return data_ + + +@config.command( + name="get_version", + help=( + "return major and minor version from config file " + "--config_path input arg is required" + "--out_path input arg is required" + ) +) +@click.option("--config_path", required=True, + help="path where to read ocio config file", + type=click.Path(exists=True)) +@click.option("--out_path", required=True, + help="path where to write output json file", + type=click.Path()) +def get_version(config_path, out_path): + """Get version of config. + + Python 2 wrapped console command + + Args: + config_path (str): ocio config file path string + out_path (str): temp json file path string + + Example of use: + > pyton.exe ./ocio_wrapper.py config get_version \ + --config_path= --out_path= + """ + json_path = Path(out_path) + + out_data = _get_version_data(config_path) + + with open(json_path, "w") as f_: + json.dump(out_data, f_) + + print(f"Config version data are saved to '{json_path}'") + + +def _get_version_data(config_path): + """Return major and minor version info. + + Args: + config_path (str): path string leading to config.ocio + + Raises: + IOError: Input config does not exist. + + Returns: + dict: minor and major keys with values + """ + config_path = Path(config_path) + + if not config_path.is_file(): + raise IOError("Input path should be `config.ocio` file") + + config = ocio.Config().CreateFromFile(str(config_path)) + + return { + "major": config.getMajorVersion(), + "minor": config.getMinorVersion() + } + + +@colorspace.command( + name="get_config_file_rules_colorspace_from_filepath", + help=( + "return colorspace from filepath " + "--config_path - ocio config file path (input arg is required) " + "--filepath - any file path (input arg is required) " + "--out_path - temp json file path (input arg is required)" + ) +) +@click.option("--config_path", required=True, + help="path where to read ocio config file", + type=click.Path(exists=True)) +@click.option("--filepath", required=True, + help="path to file to get colorspace from", + type=click.Path()) +@click.option("--out_path", required=True, + help="path where to write output json file", + type=click.Path()) +def get_config_file_rules_colorspace_from_filepath( + config_path, filepath, out_path +): + """Get colorspace from file path wrapper. + + Python 2 wrapped console command + + Args: + config_path (str): config file path string + filepath (str): path string leading to file + out_path (str): temp json file path string + + Example of use: + > pyton.exe ./ocio_wrapper.py \ + colorspace get_config_file_rules_colorspace_from_filepath \ + --config_path= --filepath= --out_path= + """ + json_path = Path(out_path) + + colorspace = _get_config_file_rules_colorspace_from_filepath( + config_path, filepath) + + with open(json_path, "w") as f_: + json.dump(colorspace, f_) + + print(f"Colorspace name is saved to '{json_path}'") + + +def _get_config_file_rules_colorspace_from_filepath(config_path, filepath): + """Return found colorspace data found in v2 file rules. + + Args: + config_path (str): path string leading to config.ocio + filepath (str): path string leading to v2 file rules + + Raises: + IOError: Input config does not exist. + + Returns: + dict: aggregated available colorspaces + """ + config_path = Path(config_path) + + if not config_path.is_file(): + raise IOError( + f"Input path `{config_path}` should be `config.ocio` file") + + config = ocio.Config().CreateFromFile(str(config_path)) + + # TODO: use `parseColorSpaceFromString` instead if ocio v1 + colorspace = config.getColorSpaceFromFilepath(str(filepath)) + + return colorspace def _get_display_view_colorspace_name(config_path, display, view): diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 3be8ac8ae5..3ccb18111a 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -748,7 +748,21 @@ def _convert_nuke_project_settings(ayon_settings, output): ) new_review_data_outputs = {} - for item in ayon_publish["ExtractReviewDataMov"]["outputs"]: + outputs_settings = [] + # Check deprecated ExtractReviewDataMov + # settings for backwards compatibility + deprecrated_review_settings = ayon_publish["ExtractReviewDataMov"] + current_review_settings = ( + ayon_publish.get("ExtractReviewIntermediates") + ) + if deprecrated_review_settings["enabled"]: + outputs_settings = deprecrated_review_settings["outputs"] + elif current_review_settings is None: + pass + elif current_review_settings["enabled"]: + outputs_settings = current_review_settings["outputs"] + + for item in outputs_settings: item_filter = item["filter"] if "product_names" in item_filter: item_filter["subsets"] = item_filter.pop("product_names") @@ -767,7 +781,11 @@ def _convert_nuke_project_settings(ayon_settings, output): name = item.pop("name") new_review_data_outputs[name] = item - ayon_publish["ExtractReviewDataMov"]["outputs"] = new_review_data_outputs + + if deprecrated_review_settings["enabled"]: + deprecrated_review_settings["outputs"] = new_review_data_outputs + elif current_review_settings["enabled"]: + current_review_settings["outputs"] = new_review_data_outputs collect_instance_data = ayon_publish["CollectInstanceData"] if "sync_workfile_version_on_product_types" in collect_instance_data: @@ -1146,19 +1164,19 @@ def _convert_global_project_settings(ayon_settings, output, default_settings): for profile in extract_oiio_transcode_profiles: new_outputs = {} name_counter = {} - for output in profile["outputs"]: - if "name" in output: - name = output.pop("name") + for profile_output in profile["outputs"]: + if "name" in profile_output: + name = profile_output.pop("name") else: # Backwards compatibility for setting without 'name' in model - name = output["extension"] + name = profile_output["extension"] if name in new_outputs: name_counter[name] += 1 name = "{}_{}".format(name, name_counter[name]) else: name_counter[name] = 0 - new_outputs[name] = output + new_outputs[name] = profile_output profile["outputs"] = new_outputs # Extract Burnin plugin diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index df865adeba..f3eb31174f 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -17,6 +17,14 @@ "rules": {} } }, + "RenderSettings": { + "default_render_image_folder": "renders/blender", + "aov_separator": "underscore", + "image_format": "exr", + "multilayer_exr": true, + "aov_list": [], + "custom_passes": [] + }, "workfile_builder": { "create_first_version": false, "custom_templates": [] @@ -27,6 +35,22 @@ "optional": true, "active": true }, + "ValidateFileSaved": { + "enabled": true, + "optional": false, + "active": true, + "exclude_families": [] + }, + "ValidateRenderCameraIsSet": { + "enabled": true, + "optional": false, + "active": true + }, + "ValidateDeadlinePublish": { + "enabled": true, + "optional": false, + "active": true + }, "ValidateMeshHasUvs": { "enabled": true, "optional": true, diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 1b8c8397d7..2c5e0dc65d 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -52,7 +52,8 @@ "priority": 50, "chunk_size": 10, "concurrent_tasks": 1, - "group": "" + "group": "", + "plugin": "Fusion" }, "NukeSubmitDeadline": { "enabled": true, @@ -99,6 +100,15 @@ "deadline_chunk_size": 10, "deadline_job_delay": "00:00:00:00" }, + "BlenderSubmitDeadline": { + "enabled": true, + "optional": false, + "active": true, + "use_published": true, + "priority": 50, + "chunk_size": 10, + "group": "none" + }, "ProcessSubmittedJobOnFarm": { "enabled": true, "deadline_department": "", @@ -112,6 +122,9 @@ "maya": [ ".*([Bb]eauty).*" ], + "blender": [ + ".*([Bb]eauty).*" + ], "aftereffects": [ ".*" ], diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 5392fc34dd..4f57ee52c6 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -1,4 +1,16 @@ { + "general": { + "update_houdini_var_context": { + "enabled": true, + "houdini_vars":[ + { + "var": "JOB", + "value": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}", + "is_directory": true + } + ] + } + }, "imageio": { "activate_host_color_management": true, "ocio_config": { diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 38f14ec022..7719a5e255 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -707,6 +707,9 @@ "CollectMayaRender": { "sync_workfile_version": false }, + "CollectFbxAnimation": { + "enabled": true + }, "CollectFbxCamera": { "enabled": false }, @@ -826,6 +829,11 @@ "redshift_render_attributes": [], "renderman_render_attributes": [] }, + "ValidateResolution": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateCurrentRenderLayerIsRenderable": { "enabled": true, "optional": false, @@ -1120,6 +1128,11 @@ "optional": true, "active": true }, + "ValidateAnimatedReferenceRig": { + "enabled": true, + "optional": false, + "active": true + }, "ValidateAnimationContent": { "enabled": true, "optional": false, @@ -1140,6 +1153,16 @@ "optional": false, "active": true }, + "ValidateSkeletonRigContents": { + "enabled": true, + "optional": true, + "active": true + }, + "ValidateSkeletonRigControllers": { + "enabled": false, + "optional": true, + "active": true + }, "ValidateSkinclusterDeformerSet": { "enabled": true, "optional": false, @@ -1150,6 +1173,21 @@ "optional": false, "allow_history_only": false }, + "ValidateSkeletonRigOutSetNodeIds": { + "enabled": false, + "optional": false, + "allow_history_only": false + }, + "ValidateSkeletonRigOutputIds": { + "enabled": false, + "optional": true, + "active": true + }, + "ValidateSkeletonTopGroupHierarchy": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateCameraAttributes": { "enabled": false, "optional": true, @@ -1338,6 +1376,12 @@ "active": true, "bake_attributes": [] }, + "ExtractCameraMayaScene": { + "enabled": true, + "optional": true, + "active": true, + "keep_image_planes": false + }, "ExtractGLB": { "enabled": true, "active": true, diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 7961e77113..3b69ef54fd 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -341,7 +341,7 @@ "write" ] }, - "ValidateCorrectAssetName": { + "ValidateCorrectAssetContext": { "enabled": true, "optional": true, "active": true @@ -501,6 +501,60 @@ } } }, + "ExtractReviewIntermediates": { + "enabled": true, + "viewer_lut_raw": false, + "outputs": { + "baking": { + "filter": { + "task_types": [], + "families": [], + "subsets": [] + }, + "read_raw": false, + "viewer_process_override": "", + "bake_viewer_process": true, + "bake_viewer_input_process": true, + "reformat_nodes_config": { + "enabled": false, + "reposition_nodes": [ + { + "node_class": "Reformat", + "knobs": [ + { + "type": "text", + "name": "type", + "value": "to format" + }, + { + "type": "text", + "name": "format", + "value": "HD_1080" + }, + { + "type": "text", + "name": "filter", + "value": "Lanczos6" + }, + { + "type": "bool", + "name": "black_outside", + "value": true + }, + { + "type": "bool", + "name": "pbb", + "value": false + } + ] + } + ] + }, + "extension": "mov", + "add_custom_tags": [] + } + } + }, "ExtractSlateFrame": { "viewer_lut_raw": false, "key_value_mapping": { diff --git a/openpype/settings/defaults/project_settings/traypublisher.json b/openpype/settings/defaults/project_settings/traypublisher.json index 7f7b7d1452..e13de11414 100644 --- a/openpype/settings/defaults/project_settings/traypublisher.json +++ b/openpype/settings/defaults/project_settings/traypublisher.json @@ -346,10 +346,10 @@ } }, "publish": { - "CollectFrameDataFromAssetEntity": { + "CollectSequenceFrameData": { "enabled": true, "optional": true, - "active": true + "active": false }, "ValidateFrameRange": { "enabled": true, diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index f2fc7d933a..2cb75a9515 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -114,6 +114,65 @@ } } }, + "mayapy": { + "enabled": true, + "label": "MayaPy", + "icon": "{}/app_icons/maya.png", + "host_name": "maya", + "environment": { + "MAYA_DISABLE_CLIC_IPM": "Yes", + "MAYA_DISABLE_CIP": "Yes", + "MAYA_DISABLE_CER": "Yes", + "PYMEL_SKIP_MEL_INIT": "Yes", + "LC_ALL": "C" + }, + "variants": { + "2024": { + "use_python_2": false, + "executables": { + "windows": [ + "C:\\Program Files\\Autodesk\\Maya2024\\bin\\mayapy.exe" + ], + "darwin": [], + "linux": [ + "/usr/autodesk/maya2024/bin/mayapy" + ] + }, + "arguments": { + "windows": [ + "-I" + ], + "darwin": [], + "linux": [ + "-I" + ] + }, + "environment": {} + }, + "2023": { + "use_python_2": false, + "executables": { + "windows": [ + "C:\\Program Files\\Autodesk\\Maya2023\\bin\\mayapy.exe" + ], + "darwin": [], + "linux": [ + "/usr/autodesk/maya2023/bin/mayapy" + ] + }, + "arguments": { + "windows": [ + "-I" + ], + "darwin": [], + "linux": [ + "-I" + ] + }, + "environment": {} + } + } + }, "3dsmax": { "enabled": true, "label": "3ds max", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json index aeb70dfd8c..535d9434a3 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json @@ -54,6 +54,110 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "RenderSettings", + "label": "Render Settings", + "children": [ + { + "type": "text", + "key": "default_render_image_folder", + "label": "Default render image folder" + }, + { + "key": "aov_separator", + "label": "AOV Separator Character", + "type": "enum", + "multiselection": false, + "defaults": "underscore", + "enum_items": [ + {"dash": "- (dash)"}, + {"underscore": "_ (underscore)"}, + {"dot": ". (dot)"} + ] + }, + { + "key": "image_format", + "label": "Output Image Format", + "type": "enum", + "multiselection": false, + "defaults": "exr", + "enum_items": [ + {"exr": "OpenEXR"}, + {"bmp": "BMP"}, + {"rgb": "Iris"}, + {"png": "PNG"}, + {"jpg": "JPEG"}, + {"jp2": "JPEG 2000"}, + {"tga": "Targa"}, + {"tif": "TIFF"} + ] + }, + { + "key": "multilayer_exr", + "type": "boolean", + "label": "Multilayer (EXR)" + }, + { + "type": "label", + "label": "Note: Multilayer EXR is only used when output format type set to EXR." + }, + { + "key": "aov_list", + "label": "AOVs to create", + "type": "enum", + "multiselection": true, + "defaults": "empty", + "enum_items": [ + {"empty": "< empty >"}, + {"combined": "Combined"}, + {"z": "Z"}, + {"mist": "Mist"}, + {"normal": "Normal"}, + {"diffuse_light": "Diffuse Light"}, + {"diffuse_color": "Diffuse Color"}, + {"specular_light": "Specular Light"}, + {"specular_color": "Specular Color"}, + {"volume_light": "Volume Light"}, + {"emission": "Emission"}, + {"environment": "Environment"}, + {"shadow": "Shadow"}, + {"ao": "Ambient Occlusion"}, + {"denoising": "Denoising"}, + {"volume_direct": "Direct Volumetric Scattering"}, + {"volume_indirect": "Indirect Volumetric Scattering"} + ] + }, + { + "type": "label", + "label": "Add custom AOVs. They are added to the view layer and in the Compositing Nodetree,\nbut they need to be added manually to the Shader Nodetree." + }, + { + "type": "dict-modifiable", + "store_as_list": true, + "key": "custom_passes", + "label": "Custom Passes", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "type", + "label": "Type", + "type": "enum", + "multiselection": false, + "default": "COLOR", + "enum_items": [ + {"COLOR": "Color"}, + {"VALUE": "Value"} + ] + } + ] + } + } + ] + }, { "type": "schema_template", "name": "template_workfile_options", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index 6d59b5a92b..64db852c89 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -289,6 +289,15 @@ "type": "text", "key": "group", "label": "Group Name" + }, + { + "type": "enum", + "key": "plugin", + "label": "Deadline Plugin", + "enum_items": [ + {"Fusion": "Fusion"}, + {"FusionCmd": "FusionCmd"} + ] } ] }, @@ -531,6 +540,50 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "BlenderSubmitDeadline", + "label": "Blender Submit to Deadline", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "boolean", + "key": "use_published", + "label": "Use Published scene" + }, + { + "type": "number", + "key": "priority", + "label": "Priority" + }, + { + "type": "number", + "key": "chunk_size", + "label": "Frame per Task" + }, + { + "type": "text", + "key": "group", + "label": "Group Name" + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json b/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json index 7f782e3647..d4d0565ec9 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json @@ -5,6 +5,10 @@ "label": "Houdini", "is_file": true, "children": [ + { + "type": "schema", + "name": "schema_houdini_general" + }, { "key": "imageio", "type": "dict", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json index 184fc657be..93e6325b5a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json @@ -350,8 +350,8 @@ "name": "template_validate_plugin", "template_data": [ { - "key": "CollectFrameDataFromAssetEntity", - "label": "Collect frame range from asset entity" + "key": "CollectSequenceFrameData", + "label": "Collect Original Sequence Frame Data" }, { "key": "ValidateFrameRange", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json index 2f0bf0a831..7f1a8a915b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json @@ -18,6 +18,39 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateFileSaved", + "label": "Validate File Saved", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "splitter" + }, + { + "key": "exclude_families", + "label": "Exclude Families", + "type": "list", + "object_type": "text" + } + ] + }, { "type": "collapsible-wrap", "label": "Model", @@ -46,6 +79,66 @@ } ] }, + { + "type": "collapsible-wrap", + "label": "Render", + "children": [ + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "type": "dict", + "collapsible": true, + "key": "ValidateRenderCameraIsSet", + "label": "Validate Render Camera Is Set", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateDeadlinePublish", + "label": "Validate Render Output for Deadline", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + } + ] + } + ] + } + ] + }, { "type": "splitter" }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json new file mode 100644 index 0000000000..de1a0396ec --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json @@ -0,0 +1,53 @@ +{ + "type": "dict", + "key": "general", + "label": "General", + "collapsible": true, + "is_group": true, + "children": [ + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "update_houdini_var_context", + "label": "Update Houdini Vars on context change", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "label", + "label": "Sync vars with context changes.
If a value is treated as a directory on update it will be ensured the folder exists" + }, + { + "type": "list", + "key": "houdini_vars", + "label": "Houdini Vars", + "collapsible": false, + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "var", + "label": "Var" + }, + { + "type": "text", + "key": "value", + "label": "Value" + }, + { + "type": "boolean", + "key": "is_directory", + "label": "Treat as directory" + } + ] + } + } + ] + } + ] +} 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 b115ee3faa..d2e7c51e24 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 @@ -21,6 +21,20 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CollectFbxAnimation", + "label": "Collect Fbx Animation", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, { "type": "dict", "collapsible": true, @@ -417,6 +431,10 @@ "type": "schema_template", "name": "template_publish_plugin", "template_data": [ + { + "key": "ValidateResolution", + "label": "Validate Resolution Settings" + }, { "key": "ValidateCurrentRenderLayerIsRenderable", "label": "Validate Current Render Layer Has Renderable Camera" @@ -793,6 +811,10 @@ "key": "ValidateRigControllers", "label": "Validate Rig Controllers" }, + { + "key": "ValidateAnimatedReferenceRig", + "label": "Validate Animated Reference Rig" + }, { "key": "ValidateAnimationContent", "label": "Validate Animation Content" @@ -809,9 +831,51 @@ "key": "ValidateSkeletalMeshHierarchy", "label": "Validate Skeletal Mesh Top Node" }, - { + { + "key": "ValidateSkeletonRigContents", + "label": "Validate Skeleton Rig Contents" + }, + { + "key": "ValidateSkeletonRigControllers", + "label": "Validate Skeleton Rig Controllers" + }, + { "key": "ValidateSkinclusterDeformerSet", "label": "Validate Skincluster Deformer Relationships" + }, + { + "key": "ValidateSkeletonRigOutputIds", + "label": "Validate Skeleton Rig Output Ids" + }, + { + "key": "ValidateSkeletonTopGroupHierarchy", + "label": "Validate Skeleton Top Group Hierarchy" + } + ] + }, + + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "ValidateRigOutSetNodeIds", + "label": "Validate Rig Out Set Node Ids", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "allow_history_only", + "label": "Allow history only" } ] }, @@ -819,8 +883,8 @@ "type": "dict", "collapsible": true, "checkbox_key": "enabled", - "key": "ValidateRigOutSetNodeIds", - "label": "Validate Rig Out Set Node Ids", + "key": "ValidateSkeletonRigOutSetNodeIds", + "label": "Validate Skeleton Rig Out Set Node Ids", "is_group": true, "children": [ { @@ -978,6 +1042,35 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "ExtractCameraMayaScene", + "label": "Extract camera to Maya scene", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "boolean", + "key": "keep_image_planes", + "label": "Export Image planes" + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index f006392bef..9e012e560f 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -61,7 +61,7 @@ "name": "template_publish_plugin", "template_data": [ { - "key": "ValidateCorrectAssetName", + "key": "ValidateCorrectAssetContext", "label": "Validate Correct Asset Name" } ] @@ -371,6 +371,151 @@ ] }, + { + "type": "label", + "label": "^ Settings and for ExtractReviewDataMov is deprecated and will be soon removed.
Please use ExtractReviewIntermediates instead." + }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "ExtractReviewIntermediates", + "label": "ExtractReviewIntermediates", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "viewer_lut_raw", + "label": "Viewer LUT raw" + }, + { + "key": "outputs", + "label": "Output Definitions", + "type": "dict-modifiable", + "highlight_content": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "dict", + "collapsible": false, + "key": "filter", + "label": "Filtering", + "children": [ + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, + { + "key": "subsets", + "label": "Subsets", + "type": "list", + "object_type": "text" + } + ] + }, + { + "type": "separator" + }, + { + "type": "boolean", + "key": "read_raw", + "label": "Read colorspace RAW", + "default": false + }, + { + "type": "text", + "key": "viewer_process_override", + "label": "Viewer Process colorspace profile override" + }, + { + "type": "boolean", + "key": "bake_viewer_process", + "label": "Bake Viewer Process" + }, + { + "type": "boolean", + "key": "bake_viewer_input_process", + "label": "Bake Viewer Input Process (LUTs)" + }, + { + "type": "separator" + }, + { + "key": "reformat_nodes_config", + "type": "dict", + "label": "Reformat Nodes", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "label", + "label": "Reposition knobs supported only.
You can add multiple reformat nodes
and set their knobs. Order of reformat
nodes is important. First reformat node
will be applied first and last reformat
node will be applied last." + }, + { + "key": "reposition_nodes", + "type": "list", + "label": "Reposition nodes", + "object_type": { + "type": "dict", + "children": [ + { + "key": "node_class", + "label": "Node class", + "type": "text" + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] + } + ] + } + } + ] + }, + { + "type": "separator" + }, + { + "type": "text", + "key": "extension", + "label": "Write node file type" + }, + { + "key": "add_custom_tags", + "label": "Add custom tags", + "type": "list", + "object_type": "text" + } + ] + } + } + + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_mayapy.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_mayapy.json new file mode 100644 index 0000000000..bbdc7e13b0 --- /dev/null +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_mayapy.json @@ -0,0 +1,39 @@ +{ + "type": "dict", + "key": "mayapy", + "label": "Autodesk MayaPy", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "schema_template", + "name": "template_host_unchangables" + }, + { + "key": "environment", + "label": "Environment", + "type": "raw-json" + }, + { + "type": "dict-modifiable", + "key": "variants", + "collapsible_key": true, + "use_label_wrap": false, + "object_type": { + "type": "dict", + "collapsible": true, + "children": [ + { + "type": "schema_template", + "name": "template_host_variant_items" + } + ] + } + } + ] +} diff --git a/openpype/settings/entities/schemas/system_schema/schema_applications.json b/openpype/settings/entities/schemas/system_schema/schema_applications.json index abea37a9ab..7965c344ae 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_applications.json +++ b/openpype/settings/entities/schemas/system_schema/schema_applications.json @@ -9,6 +9,10 @@ "type": "schema", "name": "schema_maya" }, + { + "type": "schema", + "name": "schema_mayapy" + }, { "type": "schema", "name": "schema_3dsmax" diff --git a/openpype/tools/ayon_launcher/abstract.py b/openpype/tools/ayon_launcher/abstract.py index 00502fe930..95fe2b2c8d 100644 --- a/openpype/tools/ayon_launcher/abstract.py +++ b/openpype/tools/ayon_launcher/abstract.py @@ -272,7 +272,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): @abstractmethod def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_id, enabled + self, project_name, folder_id, task_id, action_ids, enabled ): """This is application action related to force not open last workfile. @@ -280,7 +280,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): project_name (Union[str, None]): Project name. folder_id (Union[str, None]): Folder id. task_id (Union[str, None]): Task id. - action_id (str): Action identifier. + action_id (Iterable[str]): Action identifiers. enabled (bool): New value of force not open workfile. """ @@ -295,3 +295,13 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): """ pass + + @abstractmethod + def refresh_actions(self): + """Refresh actions and all related data. + + Triggers 'controller.refresh.actions.started' event at the beginning + and 'controller.refresh.actions.finished' at the end. + """ + + pass diff --git a/openpype/tools/ayon_launcher/control.py b/openpype/tools/ayon_launcher/control.py index 09e07893c3..36c0536422 100644 --- a/openpype/tools/ayon_launcher/control.py +++ b/openpype/tools/ayon_launcher/control.py @@ -121,10 +121,10 @@ class BaseLauncherController( project_name, folder_id, task_id) def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_id, enabled + self, project_name, folder_id, task_id, action_ids, enabled ): self._actions_model.set_application_force_not_open_workfile( - project_name, folder_id, task_id, action_id, enabled + project_name, folder_id, task_id, action_ids, enabled ) def trigger_action(self, project_name, folder_id, task_id, identifier): @@ -145,5 +145,17 @@ class BaseLauncherController( self._emit_event("controller.refresh.finished") + def refresh_actions(self): + self._emit_event("controller.refresh.actions.started") + + # Refresh project settings (used for actions discovery) + self._project_settings = {} + # Refresh projects - they define applications + self._projects_model.reset() + # Refresh actions + self._actions_model.refresh() + + self._emit_event("controller.refresh.actions.finished") + def _emit_event(self, topic, data=None): self.emit_event(topic, data, "controller") diff --git a/openpype/tools/ayon_launcher/models/actions.py b/openpype/tools/ayon_launcher/models/actions.py index 24fea44db2..93ec115734 100644 --- a/openpype/tools/ayon_launcher/models/actions.py +++ b/openpype/tools/ayon_launcher/models/actions.py @@ -326,13 +326,14 @@ class ActionsModel: return output def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_id, enabled + self, project_name, folder_id, task_id, action_ids, enabled ): no_workfile_reg_data = self._get_no_last_workfile_reg_data() project_data = no_workfile_reg_data.setdefault(project_name, {}) folder_data = project_data.setdefault(folder_id, {}) task_data = folder_data.setdefault(task_id, {}) - task_data[action_id] = enabled + for action_id in action_ids: + task_data[action_id] = enabled self._launcher_tool_reg.set_item( self._not_open_workfile_reg_key, no_workfile_reg_data ) @@ -359,7 +360,10 @@ class ActionsModel: project_name, folder_id, task_id ) force_not_open_workfile = per_action.get(identifier, False) - action.data["start_last_workfile"] = force_not_open_workfile + if force_not_open_workfile: + action.data["start_last_workfile"] = False + else: + action.data.pop("start_last_workfile", None) action.process(session) except Exception as exc: self.log.warning("Action trigger failed.", exc_info=True) diff --git a/openpype/tools/ayon_launcher/ui/actions_widget.py b/openpype/tools/ayon_launcher/ui/actions_widget.py index d04f8f8d24..2a1a06695d 100644 --- a/openpype/tools/ayon_launcher/ui/actions_widget.py +++ b/openpype/tools/ayon_launcher/ui/actions_widget.py @@ -19,6 +19,21 @@ ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 6 FORCE_NOT_OPEN_WORKFILE_ROLE = QtCore.Qt.UserRole + 7 +def _variant_label_sort_getter(action_item): + """Get variant label value for sorting. + + Make sure the output value is a string. + + Args: + action_item (ActionItem): Action item. + + Returns: + str: Variant label or empty string. + """ + + return action_item.variant_label or "" + + class ActionsQtModel(QtGui.QStandardItemModel): """Qt model for actions. @@ -31,10 +46,6 @@ class ActionsQtModel(QtGui.QStandardItemModel): def __init__(self, controller): super(ActionsQtModel, self).__init__() - controller.register_event_callback( - "controller.refresh.finished", - self._on_controller_refresh_finished, - ) controller.register_event_callback( "selection.project.changed", self._on_selection_project_changed, @@ -51,6 +62,7 @@ class ActionsQtModel(QtGui.QStandardItemModel): self._controller = controller self._items_by_id = {} + self._action_items_by_id = {} self._groups_by_id = {} self._selected_project_name = None @@ -72,8 +84,12 @@ class ActionsQtModel(QtGui.QStandardItemModel): def get_item_by_id(self, action_id): return self._items_by_id.get(action_id) + def get_action_item_by_id(self, action_id): + return self._action_items_by_id.get(action_id) + def _clear_items(self): self._items_by_id = {} + self._action_items_by_id = {} self._groups_by_id = {} root = self.invisibleRootItem() root.removeRows(0, root.rowCount()) @@ -101,12 +117,14 @@ class ActionsQtModel(QtGui.QStandardItemModel): groups_by_id = {} for action_items in items_by_label.values(): + action_items.sort(key=_variant_label_sort_getter, reverse=True) first_item = next(iter(action_items)) all_action_items_info.append((first_item, len(action_items) > 1)) groups_by_id[first_item.identifier] = action_items new_items = [] items_by_id = {} + action_items_by_id = {} for action_item_info in all_action_items_info: action_item, is_group = action_item_info icon = get_qt_icon(action_item.icon) @@ -132,6 +150,7 @@ class ActionsQtModel(QtGui.QStandardItemModel): action_item.force_not_open_workfile, FORCE_NOT_OPEN_WORKFILE_ROLE) items_by_id[action_item.identifier] = item + action_items_by_id[action_item.identifier] = action_item if new_items: root_item.appendRows(new_items) @@ -139,19 +158,14 @@ class ActionsQtModel(QtGui.QStandardItemModel): to_remove = set(self._items_by_id.keys()) - set(items_by_id.keys()) for identifier in to_remove: item = self._items_by_id.pop(identifier) + self._action_items_by_id.pop(identifier) root_item.removeRow(item.row()) self._groups_by_id = groups_by_id self._items_by_id = items_by_id + self._action_items_by_id = action_items_by_id self.refreshed.emit() - def _on_controller_refresh_finished(self): - context = self._controller.get_selected_context() - self._selected_project_name = context["project_name"] - self._selected_folder_id = context["folder_id"] - self._selected_task_id = context["task_id"] - self.refresh() - def _on_selection_project_changed(self, event): self._selected_project_name = event["project_name"] self._selected_folder_id = None @@ -336,6 +350,9 @@ class ActionsWidget(QtWidgets.QWidget): self._set_row_height(1) + def refresh(self): + self._model.refresh() + def _set_row_height(self, rows): self.setMinimumHeight(rows * 75) @@ -387,9 +404,15 @@ class ActionsWidget(QtWidgets.QWidget): checkbox.setChecked(True) action_id = index.data(ACTION_ID_ROLE) + is_group = index.data(ACTION_IS_GROUP_ROLE) + if is_group: + action_items = self._model.get_group_items(action_id) + else: + action_items = [self._model.get_action_item_by_id(action_id)] + action_ids = {action_item.identifier for action_item in action_items} checkbox.stateChanged.connect( lambda: self._on_checkbox_changed( - action_id, checkbox.isChecked() + action_ids, checkbox.isChecked() ) ) action = QtWidgets.QWidgetAction(menu) @@ -402,7 +425,7 @@ class ActionsWidget(QtWidgets.QWidget): menu.exec_(global_point) self._context_menu = None - def _on_checkbox_changed(self, action_id, is_checked): + def _on_checkbox_changed(self, action_ids, is_checked): if self._context_menu is not None: self._context_menu.close() @@ -410,7 +433,7 @@ class ActionsWidget(QtWidgets.QWidget): folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() self._controller.set_application_force_not_open_workfile( - project_name, folder_id, task_id, action_id, is_checked) + project_name, folder_id, task_id, action_ids, is_checked) self._model.refresh() def _on_clicked(self, index): diff --git a/openpype/tools/ayon_launcher/ui/hierarchy_page.py b/openpype/tools/ayon_launcher/ui/hierarchy_page.py index 5047cdc692..d56d43fdec 100644 --- a/openpype/tools/ayon_launcher/ui/hierarchy_page.py +++ b/openpype/tools/ayon_launcher/ui/hierarchy_page.py @@ -92,6 +92,10 @@ class HierarchyPage(QtWidgets.QWidget): if visible and project_name: self._projects_combobox.set_selection(project_name) + def refresh(self): + self._folders_widget.refresh() + self._tasks_widget.refresh() + def _on_back_clicked(self): self._controller.set_selected_project(None) @@ -99,4 +103,4 @@ class HierarchyPage(QtWidgets.QWidget): self._controller.refresh() def _on_filter_text_changed(self, text): - self._folders_widget.set_name_filer(text) + self._folders_widget.set_name_filter(text) diff --git a/openpype/tools/ayon_launcher/ui/projects_widget.py b/openpype/tools/ayon_launcher/ui/projects_widget.py index baa399d0ed..7dbaec5147 100644 --- a/openpype/tools/ayon_launcher/ui/projects_widget.py +++ b/openpype/tools/ayon_launcher/ui/projects_widget.py @@ -73,6 +73,9 @@ class ProjectIconView(QtWidgets.QListView): class ProjectsWidget(QtWidgets.QWidget): """Projects Page""" + + refreshed = QtCore.Signal() + def __init__(self, controller, parent=None): super(ProjectsWidget, self).__init__(parent=parent) @@ -104,6 +107,7 @@ class ProjectsWidget(QtWidgets.QWidget): main_layout.addWidget(projects_view, 1) projects_view.clicked.connect(self._on_view_clicked) + projects_model.refreshed.connect(self.refreshed) projects_filter_text.textChanged.connect( self._on_project_filter_change) refresh_btn.clicked.connect(self._on_refresh_clicked) @@ -119,6 +123,15 @@ class ProjectsWidget(QtWidgets.QWidget): self._projects_model = projects_model self._projects_proxy_model = projects_proxy_model + def has_content(self): + """Model has at least one project. + + Returns: + bool: True if there is any content in the model. + """ + + return self._projects_model.has_content() + def _on_view_clicked(self, index): if index.isValid(): project_name = index.data(QtCore.Qt.DisplayRole) diff --git a/openpype/tools/ayon_launcher/ui/window.py b/openpype/tools/ayon_launcher/ui/window.py index 139da42a2e..ffc74a2fdc 100644 --- a/openpype/tools/ayon_launcher/ui/window.py +++ b/openpype/tools/ayon_launcher/ui/window.py @@ -99,8 +99,8 @@ class LauncherWindow(QtWidgets.QWidget): message_timer.setInterval(self.message_interval) message_timer.setSingleShot(True) - refresh_timer = QtCore.QTimer() - refresh_timer.setInterval(self.refresh_interval) + actions_refresh_timer = QtCore.QTimer() + actions_refresh_timer.setInterval(self.refresh_interval) page_slide_anim = QtCore.QVariantAnimation(self) page_slide_anim.setDuration(self.page_side_anim_interval) @@ -108,8 +108,10 @@ class LauncherWindow(QtWidgets.QWidget): page_slide_anim.setEndValue(1.0) page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad) + projects_page.refreshed.connect(self._on_projects_refresh) message_timer.timeout.connect(self._on_message_timeout) - refresh_timer.timeout.connect(self._on_refresh_timeout) + actions_refresh_timer.timeout.connect( + self._on_actions_refresh_timeout) page_slide_anim.valueChanged.connect( self._on_page_slide_value_changed) page_slide_anim.finished.connect(self._on_page_slide_finished) @@ -132,6 +134,7 @@ class LauncherWindow(QtWidgets.QWidget): self._is_on_projects_page = True self._window_is_active = False self._refresh_on_activate = False + self._selected_project_name = None self._pages_widget = pages_widget self._pages_layout = pages_layout @@ -143,7 +146,7 @@ class LauncherWindow(QtWidgets.QWidget): # self._action_history = action_history self._message_timer = message_timer - self._refresh_timer = refresh_timer + self._actions_refresh_timer = actions_refresh_timer self._page_slide_anim = page_slide_anim hierarchy_page.setVisible(not self._is_on_projects_page) @@ -152,14 +155,14 @@ class LauncherWindow(QtWidgets.QWidget): def showEvent(self, event): super(LauncherWindow, self).showEvent(event) self._window_is_active = True - if not self._refresh_timer.isActive(): - self._refresh_timer.start() + if not self._actions_refresh_timer.isActive(): + self._actions_refresh_timer.start() self._controller.refresh() def closeEvent(self, event): super(LauncherWindow, self).closeEvent(event) self._window_is_active = False - self._refresh_timer.stop() + self._actions_refresh_timer.stop() def changeEvent(self, event): if event.type() in ( @@ -170,15 +173,15 @@ class LauncherWindow(QtWidgets.QWidget): self._window_is_active = is_active if is_active and self._refresh_on_activate: self._refresh_on_activate = False - self._on_refresh_timeout() - self._refresh_timer.start() + self._on_actions_refresh_timeout() + self._actions_refresh_timer.start() super(LauncherWindow, self).changeEvent(event) - def _on_refresh_timeout(self): + def _on_actions_refresh_timeout(self): # Stop timer if widget is not visible if self._window_is_active: - self._controller.refresh() + self._controller.refresh_actions() else: self._refresh_on_activate = True @@ -191,12 +194,26 @@ class LauncherWindow(QtWidgets.QWidget): def _on_project_selection_change(self, event): project_name = event["project_name"] + self._selected_project_name = project_name if not project_name: self._go_to_projects_page() elif self._is_on_projects_page: self._go_to_hierarchy_page(project_name) + def _on_projects_refresh(self): + # There is nothing to do, we're on projects page + if self._is_on_projects_page: + return + + # No projects were found -> go back to projects page + if not self._projects_page.has_content(): + self._go_to_projects_page() + return + + self._hierarchy_page.refresh() + self._actions_widget.refresh() + def _on_action_trigger_started(self, event): self._echo("Running action: {}".format(event["full_label"])) diff --git a/openpype/tools/ayon_loader/__init__.py b/openpype/tools/ayon_loader/__init__.py new file mode 100644 index 0000000000..09ecf65f3a --- /dev/null +++ b/openpype/tools/ayon_loader/__init__.py @@ -0,0 +1,6 @@ +from .control import LoaderController + + +__all__ = ( + "LoaderController", +) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py new file mode 100644 index 0000000000..45042395d9 --- /dev/null +++ b/openpype/tools/ayon_loader/abstract.py @@ -0,0 +1,851 @@ +from abc import ABCMeta, abstractmethod +import six + +from openpype.lib.attribute_definitions import ( + AbstractAttrDef, + serialize_attr_defs, + deserialize_attr_defs, +) + + +class ProductTypeItem: + """Item representing product type. + + Args: + name (str): Product type name. + icon (dict[str, Any]): Product type icon definition. + checked (bool): Is product type checked for filtering. + """ + + def __init__(self, name, icon, checked): + self.name = name + self.icon = icon + self.checked = checked + + def to_data(self): + return { + "name": self.name, + "icon": self.icon, + "checked": self.checked, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +class ProductItem: + """Product item with it versions. + + Args: + product_id (str): Product id. + product_type (str): Product type. + product_name (str): Product name. + product_icon (dict[str, Any]): Product icon definition. + product_type_icon (dict[str, Any]): Product type icon definition. + product_in_scene (bool): Is product in scene (only when used in DCC). + group_name (str): Group name. + folder_id (str): Folder id. + folder_label (str): Folder label. + version_items (dict[str, VersionItem]): Version items by id. + """ + + def __init__( + self, + product_id, + product_type, + product_name, + product_icon, + product_type_icon, + product_in_scene, + group_name, + folder_id, + folder_label, + version_items, + ): + self.product_id = product_id + self.product_type = product_type + self.product_name = product_name + self.product_icon = product_icon + self.product_type_icon = product_type_icon + self.product_in_scene = product_in_scene + self.group_name = group_name + self.folder_id = folder_id + self.folder_label = folder_label + self.version_items = version_items + + def to_data(self): + return { + "product_id": self.product_id, + "product_type": self.product_type, + "product_name": self.product_name, + "product_icon": self.product_icon, + "product_type_icon": self.product_type_icon, + "product_in_scene": self.product_in_scene, + "group_name": self.group_name, + "folder_id": self.folder_id, + "folder_label": self.folder_label, + "version_items": { + version_id: version_item.to_data() + for version_id, version_item in self.version_items.items() + }, + } + + @classmethod + def from_data(cls, data): + version_items = { + version_id: VersionItem.from_data(version) + for version_id, version in data["version_items"].items() + } + data["version_items"] = version_items + return cls(**data) + + +class VersionItem: + """Version item. + + Object have implemented comparison operators to be sortable. + + Args: + version_id (str): Version id. + version (int): Version. Can be negative when is hero version. + is_hero (bool): Is hero version. + product_id (str): Product id. + thumbnail_id (Union[str, None]): Thumbnail id. + published_time (Union[str, None]): Published time in format + '%Y%m%dT%H%M%SZ'. + author (Union[str, None]): Author. + frame_range (Union[str, None]): Frame range. + duration (Union[int, None]): Duration. + handles (Union[str, None]): Handles. + step (Union[int, None]): Step. + comment (Union[str, None]): Comment. + source (Union[str, None]): Source. + """ + + def __init__( + self, + version_id, + version, + is_hero, + product_id, + thumbnail_id, + published_time, + author, + frame_range, + duration, + handles, + step, + comment, + source + ): + self.version_id = version_id + self.product_id = product_id + self.thumbnail_id = thumbnail_id + self.version = version + self.is_hero = is_hero + self.published_time = published_time + self.author = author + self.frame_range = frame_range + self.duration = duration + self.handles = handles + self.step = step + self.comment = comment + self.source = source + + def __eq__(self, other): + if not isinstance(other, VersionItem): + return False + return ( + self.is_hero == other.is_hero + and self.version == other.version + and self.version_id == other.version_id + and self.product_id == other.product_id + ) + + def __ne__(self, other): + return not self.__eq__(other) + + def __gt__(self, other): + if not isinstance(other, VersionItem): + return False + if ( + other.version == self.version + and self.is_hero + ): + return True + return other.version < self.version + + def to_data(self): + return { + "version_id": self.version_id, + "product_id": self.product_id, + "thumbnail_id": self.thumbnail_id, + "version": self.version, + "is_hero": self.is_hero, + "published_time": self.published_time, + "author": self.author, + "frame_range": self.frame_range, + "duration": self.duration, + "handles": self.handles, + "step": self.step, + "comment": self.comment, + "source": self.source, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +class RepreItem: + """Representation item. + + Args: + representation_id (str): Representation id. + representation_name (str): Representation name. + representation_icon (dict[str, Any]): Representation icon definition. + product_name (str): Product name. + folder_label (str): Folder label. + """ + + def __init__( + self, + representation_id, + representation_name, + representation_icon, + product_name, + folder_label, + ): + self.representation_id = representation_id + self.representation_name = representation_name + self.representation_icon = representation_icon + self.product_name = product_name + self.folder_label = folder_label + + def to_data(self): + return { + "representation_id": self.representation_id, + "representation_name": self.representation_name, + "representation_icon": self.representation_icon, + "product_name": self.product_name, + "folder_label": self.folder_label, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +class ActionItem: + """Action item that can be triggered. + + Action item is defined for a specific context. To trigger the action + use 'identifier' and context, it necessary also use 'options'. + + Args: + identifier (str): Action identifier. + label (str): Action label. + icon (dict[str, Any]): Action icon definition. + tooltip (str): Action tooltip. + options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): + Action options. Note: 'qargparse' is considered as deprecated. + order (int): Action order. + project_name (str): Project name. + folder_ids (list[str]): Folder ids. + product_ids (list[str]): Product ids. + version_ids (list[str]): Version ids. + representation_ids (list[str]): Representation ids. + """ + + def __init__( + self, + identifier, + label, + icon, + tooltip, + options, + order, + project_name, + folder_ids, + product_ids, + version_ids, + representation_ids, + ): + self.identifier = identifier + self.label = label + self.icon = icon + self.tooltip = tooltip + self.options = options + self.order = order + self.project_name = project_name + self.folder_ids = folder_ids + self.product_ids = product_ids + self.version_ids = version_ids + self.representation_ids = representation_ids + + def _options_to_data(self): + options = self.options + if not options: + return options + if isinstance(options[0], AbstractAttrDef): + return serialize_attr_defs(options) + # NOTE: Data conversion is not used by default in loader tool. But for + # future development of detached UI tools it would be better to be + # prepared for it. + raise NotImplementedError( + "{}.to_data is not implemented. Use Attribute definitions" + " from 'openpype.lib' instead of 'qargparse'.".format( + self.__class__.__name__ + ) + ) + + def to_data(self): + options = self._options_to_data() + return { + "identifier": self.identifier, + "label": self.label, + "icon": self.icon, + "tooltip": self.tooltip, + "options": options, + "order": self.order, + "project_name": self.project_name, + "folder_ids": self.folder_ids, + "product_ids": self.product_ids, + "version_ids": self.version_ids, + "representation_ids": self.representation_ids, + } + + @classmethod + def from_data(cls, data): + options = data["options"] + if options: + options = deserialize_attr_defs(options) + data["options"] = options + return cls(**data) + + +@six.add_metaclass(ABCMeta) +class _BaseLoaderController(object): + """Base loader controller abstraction. + + Abstract base class that is required for both frontend and backed. + """ + + @abstractmethod + def get_current_context(self): + """Current context is a context of the current scene. + + Example output: + { + "project_name": "MyProject", + "folder_id": "0011223344-5566778-99", + "task_name": "Compositing", + } + + Returns: + dict[str, Union[str, None]]: Context data. + """ + + pass + + @abstractmethod + def reset(self): + """Reset all cached data to reload everything. + + Triggers events "controller.reset.started" and + "controller.reset.finished". + """ + + pass + + # Model wrappers + @abstractmethod + def get_folder_items(self, project_name, sender=None): + """Folder items for a project. + + Args: + project_name (str): Project name. + sender (Optional[str]): Sender who requested the name. + + Returns: + list[FolderItem]: Folder items for the project. + """ + + pass + + # Expected selection helpers + @abstractmethod + def get_expected_selection_data(self): + """Full expected selection information. + + Expected selection is a selection that may not be yet selected in UI + e.g. because of refreshing, this data tell the UI what should be + selected when they finish their refresh. + + Returns: + dict[str, Any]: Expected selection data. + """ + + pass + + @abstractmethod + def set_expected_selection(self, project_name, folder_id): + """Set expected selection. + + Args: + project_name (str): Name of project to be selected. + folder_id (str): Id of folder to be selected. + """ + + pass + + +class BackendLoaderController(_BaseLoaderController): + """Backend loader controller abstraction. + + What backend logic requires from a controller for proper logic. + """ + + @abstractmethod + def emit_event(self, topic, data=None, source=None): + """Emit event with a certain topic, data and source. + + The event should be sent to both frontend and backend. + + Args: + topic (str): Event topic name. + data (Optional[dict[str, Any]]): Event data. + source (Optional[str]): Event source. + """ + + pass + + @abstractmethod + def get_loaded_product_ids(self): + """Return set of loaded product ids. + + Returns: + set[str]: Set of loaded product ids. + """ + + pass + + +class FrontendLoaderController(_BaseLoaderController): + @abstractmethod + def register_event_callback(self, topic, callback): + """Register callback for an event topic. + + Args: + topic (str): Event topic name. + callback (func): Callback triggered when the event is emitted. + """ + + pass + + # Expected selection helpers + @abstractmethod + def expected_project_selected(self, project_name): + """Expected project was selected in frontend. + + Args: + project_name (str): Project name. + """ + + pass + + @abstractmethod + def expected_folder_selected(self, folder_id): + """Expected folder was selected in frontend. + + Args: + folder_id (str): Folder id. + """ + + pass + + # Model wrapper calls + @abstractmethod + def get_project_items(self, sender=None): + """Items for all projects available on server. + + Triggers event topics "projects.refresh.started" and + "projects.refresh.finished" with data: + { + "sender": sender + } + + Notes: + Filtering of projects is done in UI. + + Args: + sender (Optional[str]): Sender who requested the items. + + Returns: + list[ProjectItem]: List of project items. + """ + + pass + + @abstractmethod + def get_product_items(self, project_name, folder_ids, sender=None): + """Product items for folder ids. + + Triggers event topics "products.refresh.started" and + "products.refresh.finished" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "sender": sender + } + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + sender (Optional[str]): Sender who requested the items. + + Returns: + list[ProductItem]: List of product items. + """ + + pass + + @abstractmethod + def get_product_item(self, project_name, product_id): + """Receive single product item. + + Args: + project_name (str): Project name. + product_id (str): Product id. + + Returns: + Union[ProductItem, None]: Product info or None if not found. + """ + + pass + + @abstractmethod + def get_product_type_items(self, project_name): + """Product type items for a project. + + Product types have defined if are checked for filtering or not. + + Returns: + list[ProductTypeItem]: List of product type items for a project. + """ + + pass + + @abstractmethod + def get_representation_items( + self, project_name, version_ids, sender=None + ): + """Representation items for version ids. + + Triggers event topics "model.representations.refresh.started" and + "model.representations.refresh.finished" with data: + { + "project_name": project_name, + "version_ids": version_ids, + "sender": sender + } + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + sender (Optional[str]): Sender who requested the items. + + Returns: + list[RepreItem]: List of representation items. + """ + + pass + + @abstractmethod + def get_version_thumbnail_ids(self, project_name, version_ids): + """Get thumbnail ids for version ids. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + dict[str, Union[str, Any]]: Thumbnail id by version id. + """ + + pass + + @abstractmethod + def get_folder_thumbnail_ids(self, project_name, folder_ids): + """Get thumbnail ids for folder ids. + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + + Returns: + dict[str, Union[str, Any]]: Thumbnail id by folder id. + """ + + pass + + @abstractmethod + def get_thumbnail_path(self, project_name, thumbnail_id): + """Get thumbnail path for thumbnail id. + + This method should get a path to a thumbnail based on thumbnail id. + Which probably means to download the thumbnail from server and store + it locally. + + Args: + project_name (str): Project name. + thumbnail_id (str): Thumbnail id. + + Returns: + Union[str, None]: Thumbnail path or None if not found. + """ + + pass + + # Selection model wrapper calls + @abstractmethod + def get_selected_project_name(self): + """Get selected project name. + + The information is based on last selection from UI. + + Returns: + Union[str, None]: Selected project name. + """ + + pass + + @abstractmethod + def get_selected_folder_ids(self): + """Get selected folder ids. + + The information is based on last selection from UI. + + Returns: + list[str]: Selected folder ids. + """ + + pass + + @abstractmethod + def get_selected_version_ids(self): + """Get selected version ids. + + The information is based on last selection from UI. + + Returns: + list[str]: Selected version ids. + """ + + pass + + @abstractmethod + def get_selected_representation_ids(self): + """Get selected representation ids. + + The information is based on last selection from UI. + + Returns: + list[str]: Selected representation ids. + """ + + pass + + @abstractmethod + def set_selected_project(self, project_name): + """Set selected project. + + Project selection changed in UI. Method triggers event with topic + "selection.project.changed" with data: + { + "project_name": self._project_name + } + + Args: + project_name (Union[str, None]): Selected project name. + """ + + pass + + @abstractmethod + def set_selected_folders(self, folder_ids): + """Set selected folders. + + Folder selection changed in UI. Method triggers event with topic + "selection.folders.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids + } + + Args: + folder_ids (Iterable[str]): Selected folder ids. + """ + + pass + + @abstractmethod + def set_selected_versions(self, version_ids): + """Set selected versions. + + Version selection changed in UI. Method triggers event with topic + "selection.versions.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "version_ids": version_ids + } + + Args: + version_ids (Iterable[str]): Selected version ids. + """ + + pass + + @abstractmethod + def set_selected_representations(self, repre_ids): + """Set selected representations. + + Representation selection changed in UI. Method triggers event with + topic "selection.representations.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "version_ids": version_ids, + "representation_ids": representation_ids + } + + Args: + repre_ids (Iterable[str]): Selected representation ids. + """ + + pass + + # Load action items + @abstractmethod + def get_versions_action_items(self, project_name, version_ids): + """Action items for versions selection. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + list[ActionItem]: List of action items. + """ + + pass + + @abstractmethod + def get_representations_action_items( + self, project_name, representation_ids + ): + """Action items for representations selection. + + Args: + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + + Returns: + list[ActionItem]: List of action items. + """ + + pass + + @abstractmethod + def trigger_action_item( + self, + identifier, + options, + project_name, + version_ids, + representation_ids + ): + """Trigger action item. + + Triggers event "load.started" with data: + { + "identifier": identifier, + "id": , + } + + And triggers "load.finished" with data: + { + "identifier": identifier, + "id": , + "error_info": [...], + } + + Args: + identifier (str): Action identifier. + options (dict[str, Any]): Action option values from UI. + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + representation_ids (Iterable[str]): Representation ids. + """ + + pass + + @abstractmethod + def change_products_group(self, project_name, product_ids, group_name): + """Change group of products. + + Triggers event "products.group.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "product_ids": product_ids, + "group_name": group_name, + } + + Args: + project_name (str): Project name. + product_ids (Iterable[str]): Product ids. + group_name (str): New group name. + """ + + pass + + @abstractmethod + def fill_root_in_source(self, source): + """Fill root in source path. + + Args: + source (Union[str, None]): Source of a published version. Usually + rootless workfile path. + """ + + pass + + # NOTE: Methods 'is_loaded_products_supported' and + # 'is_standard_projects_filter_enabled' are both based on being in host + # or not. Maybe we could implement only single method 'is_in_host'? + @abstractmethod + def is_loaded_products_supported(self): + """Is capable to get information about loaded products. + + Returns: + bool: True if it is supported. + """ + + pass + + @abstractmethod + def is_standard_projects_filter_enabled(self): + """Is standard projects filter enabled. + + This is used for filtering out when loader tool is used in a host. In + that case only current project and library projects should be shown. + + Returns: + bool: Frontend should filter out non-library projects, except + current context project. + """ + + pass diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py new file mode 100644 index 0000000000..2b779f5c2e --- /dev/null +++ b/openpype/tools/ayon_loader/control.py @@ -0,0 +1,343 @@ +import logging + +import ayon_api + +from openpype.lib.events import QueuedEventSystem +from openpype.pipeline import Anatomy, get_current_context +from openpype.host import ILoadHost +from openpype.tools.ayon_utils.models import ( + ProjectsModel, + HierarchyModel, + NestedCacheItem, + CacheItem, + ThumbnailsModel, +) + +from .abstract import BackendLoaderController, FrontendLoaderController +from .models import SelectionModel, ProductsModel, LoaderActionsModel + + +class ExpectedSelection: + def __init__(self, controller): + self._project_name = None + self._folder_id = None + + self._project_selected = True + self._folder_selected = True + + self._controller = controller + + def _emit_change(self): + self._controller.emit_event( + "expected_selection_changed", + self.get_expected_selection_data(), + ) + + def set_expected_selection(self, project_name, folder_id): + self._project_name = project_name + self._folder_id = folder_id + + self._project_selected = False + self._folder_selected = False + self._emit_change() + + def get_expected_selection_data(self): + project_current = False + folder_current = False + if not self._project_selected: + project_current = True + elif not self._folder_selected: + folder_current = True + return { + "project": { + "name": self._project_name, + "current": project_current, + "selected": self._project_selected, + }, + "folder": { + "id": self._folder_id, + "current": folder_current, + "selected": self._folder_selected, + }, + } + + def is_expected_project_selected(self, project_name): + return project_name == self._project_name and self._project_selected + + def is_expected_folder_selected(self, folder_id): + return folder_id == self._folder_id and self._folder_selected + + def expected_project_selected(self, project_name): + if project_name != self._project_name: + return False + self._project_selected = True + self._emit_change() + return True + + def expected_folder_selected(self, folder_id): + if folder_id != self._folder_id: + return False + self._folder_selected = True + self._emit_change() + return True + + +class LoaderController(BackendLoaderController, FrontendLoaderController): + """ + + Args: + host (Optional[AbstractHost]): Host object. Defaults to None. + """ + + def __init__(self, host=None): + self._log = None + self._host = host + + self._event_system = self._create_event_system() + + self._project_anatomy_cache = NestedCacheItem( + levels=1, lifetime=60) + self._loaded_products_cache = CacheItem( + default_factory=set, lifetime=60) + + self._selection_model = SelectionModel(self) + self._expected_selection = ExpectedSelection(self) + self._projects_model = ProjectsModel(self) + self._hierarchy_model = HierarchyModel(self) + self._products_model = ProductsModel(self) + self._loader_actions_model = LoaderActionsModel(self) + self._thumbnails_model = ThumbnailsModel() + + @property + def log(self): + if self._log is None: + self._log = logging.getLogger(self.__class__.__name__) + return self._log + + # --------------------------------- + # Implementation of abstract methods + # --------------------------------- + # Events system + def emit_event(self, topic, data=None, source=None): + """Use implemented event system to trigger event.""" + + if data is None: + data = {} + self._event_system.emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self._event_system.add_callback(topic, callback) + + def reset(self): + self._emit_event("controller.reset.started") + + project_name = self.get_selected_project_name() + folder_ids = self.get_selected_folder_ids() + + self._project_anatomy_cache.reset() + self._loaded_products_cache.reset() + + self._products_model.reset() + self._hierarchy_model.reset() + self._loader_actions_model.reset() + self._projects_model.reset() + self._thumbnails_model.reset() + + self._projects_model.refresh() + + if not project_name and not folder_ids: + context = self.get_current_context() + project_name = context["project_name"] + folder_id = context["folder_id"] + self.set_expected_selection(project_name, folder_id) + + self._emit_event("controller.reset.finished") + + # Expected selection helpers + def get_expected_selection_data(self): + return self._expected_selection.get_expected_selection_data() + + def set_expected_selection(self, project_name, folder_id): + self._expected_selection.set_expected_selection( + project_name, folder_id + ) + + def expected_project_selected(self, project_name): + self._expected_selection.expected_project_selected(project_name) + + def expected_folder_selected(self, folder_id): + self._expected_selection.expected_folder_selected(folder_id) + + # Entity model wrappers + def get_project_items(self, sender=None): + return self._projects_model.get_project_items(sender) + + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_product_items(self, project_name, folder_ids, sender=None): + return self._products_model.get_product_items( + project_name, folder_ids, sender) + + def get_product_item(self, project_name, product_id): + return self._products_model.get_product_item( + project_name, product_id + ) + + def get_product_type_items(self, project_name): + return self._products_model.get_product_type_items(project_name) + + def get_representation_items( + self, project_name, version_ids, sender=None + ): + return self._products_model.get_repre_items( + project_name, version_ids, sender + ) + + def get_folder_thumbnail_ids(self, project_name, folder_ids): + return self._thumbnails_model.get_folder_thumbnail_ids( + project_name, folder_ids) + + def get_version_thumbnail_ids(self, project_name, version_ids): + return self._thumbnails_model.get_version_thumbnail_ids( + project_name, version_ids) + + def get_thumbnail_path(self, project_name, thumbnail_id): + return self._thumbnails_model.get_thumbnail_path( + project_name, thumbnail_id + ) + + def change_products_group(self, project_name, product_ids, group_name): + self._products_model.change_products_group( + project_name, product_ids, group_name + ) + + def get_versions_action_items(self, project_name, version_ids): + return self._loader_actions_model.get_versions_action_items( + project_name, version_ids) + + def get_representations_action_items( + self, project_name, representation_ids): + return self._loader_actions_model.get_representations_action_items( + project_name, representation_ids) + + def trigger_action_item( + self, + identifier, + options, + project_name, + version_ids, + representation_ids + ): + self._loader_actions_model.trigger_action_item( + identifier, + options, + project_name, + version_ids, + representation_ids + ) + + # Selection model wrappers + def get_selected_project_name(self): + return self._selection_model.get_selected_project_name() + + def set_selected_project(self, project_name): + self._selection_model.set_selected_project(project_name) + + # Selection model wrappers + def get_selected_folder_ids(self): + return self._selection_model.get_selected_folder_ids() + + def set_selected_folders(self, folder_ids): + self._selection_model.set_selected_folders(folder_ids) + + def get_selected_version_ids(self): + return self._selection_model.get_selected_version_ids() + + def set_selected_versions(self, version_ids): + self._selection_model.set_selected_versions(version_ids) + + def get_selected_representation_ids(self): + return self._selection_model.get_selected_representation_ids() + + def set_selected_representations(self, repre_ids): + self._selection_model.set_selected_representations(repre_ids) + + def fill_root_in_source(self, source): + project_name = self.get_selected_project_name() + anatomy = self._get_project_anatomy(project_name) + if anatomy is None: + return source + + try: + return anatomy.fill_root(source) + except Exception: + return source + + def get_current_context(self): + if self._host is None: + return { + "project_name": None, + "folder_id": None, + "task_name": None, + } + if hasattr(self._host, "get_current_context"): + context = self._host.get_current_context() + else: + context = get_current_context() + folder_id = None + project_name = context.get("project_name") + asset_name = context.get("asset_name") + if project_name and asset_name: + folder = ayon_api.get_folder_by_name( + project_name, asset_name, fields=["id"] + ) + if folder: + folder_id = folder["id"] + return { + "project_name": project_name, + "folder_id": folder_id, + "task_name": context.get("task_name"), + } + + def get_loaded_product_ids(self): + if self._host is None: + return set() + + context = self.get_current_context() + project_name = context["project_name"] + if not project_name: + return set() + + if not self._loaded_products_cache.is_valid: + if isinstance(self._host, ILoadHost): + containers = self._host.get_containers() + else: + containers = self._host.ls() + repre_ids = {c.get("representation") for c in containers} + repre_ids.discard(None) + product_ids = self._products_model.get_product_ids_by_repre_ids( + project_name, repre_ids + ) + self._loaded_products_cache.update_data(product_ids) + return self._loaded_products_cache.get_data() + + def is_loaded_products_supported(self): + return self._host is not None + + def is_standard_projects_filter_enabled(self): + return self._host is not None + + def _get_project_anatomy(self, project_name): + if not project_name: + return None + cache = self._project_anatomy_cache[project_name] + if not cache.is_valid: + cache.update_data(Anatomy(project_name)) + return cache.get_data() + + def _create_event_system(self): + return QueuedEventSystem() + + def _emit_event(self, topic, data=None): + self._event_system.emit(topic, data or {}, "controller") diff --git a/openpype/tools/ayon_loader/models/__init__.py b/openpype/tools/ayon_loader/models/__init__.py new file mode 100644 index 0000000000..6adfe71d86 --- /dev/null +++ b/openpype/tools/ayon_loader/models/__init__.py @@ -0,0 +1,10 @@ +from .selection import SelectionModel +from .products import ProductsModel +from .actions import LoaderActionsModel + + +__all__ = ( + "SelectionModel", + "ProductsModel", + "LoaderActionsModel", +) diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py new file mode 100644 index 0000000000..3edb04e9eb --- /dev/null +++ b/openpype/tools/ayon_loader/models/actions.py @@ -0,0 +1,870 @@ +import sys +import traceback +import inspect +import copy +import collections +import uuid + +from openpype.client import ( + get_project, + get_assets, + get_subsets, + get_versions, + get_representations, +) +from openpype.pipeline.load import ( + discover_loader_plugins, + SubsetLoaderPlugin, + filter_repre_contexts_by_loader, + get_loader_identifier, + load_with_repre_context, + load_with_subset_context, + load_with_subset_contexts, + LoadError, + IncompatibleLoaderError, +) +from openpype.tools.ayon_utils.models import NestedCacheItem +from openpype.tools.ayon_loader.abstract import ActionItem + +ACTIONS_MODEL_SENDER = "actions.model" +NOT_SET = object() + + +class LoaderActionsModel: + """Model for loader actions. + + This is probably only part of models that requires to use codebase from + 'openpype.client' because of backwards compatibility with loaders logic + which are expecting mongo documents. + + TODOs: + Deprecate 'qargparse' usage in loaders and implement conversion + of 'ActionItem' to data (and 'from_data'). + Use controller to get entities (documents) -> possible only when + loaders are able to handle AYON vs. OpenPype logic. + Add missing site sync logic, and if possible remove it from loaders. + Implement loader actions to replace load plugins. + Ask loader actions to return action items instead of guessing them. + """ + + # Cache loader plugins for some time + # NOTE Set to '0' for development + loaders_cache_lifetime = 30 + + def __init__(self, controller): + self._controller = controller + self._current_context_project = NOT_SET + self._loaders_by_identifier = NestedCacheItem( + levels=1, lifetime=self.loaders_cache_lifetime) + self._product_loaders = NestedCacheItem( + levels=1, lifetime=self.loaders_cache_lifetime) + self._repre_loaders = NestedCacheItem( + levels=1, lifetime=self.loaders_cache_lifetime) + + def reset(self): + """Reset the model with all cached items.""" + + self._current_context_project = NOT_SET + self._loaders_by_identifier.reset() + self._product_loaders.reset() + self._repre_loaders.reset() + + def get_versions_action_items(self, project_name, version_ids): + """Get action items for given version ids. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + list[ActionItem]: List of action items. + """ + + ( + version_context_by_id, + repre_context_by_id + ) = self._contexts_for_versions( + project_name, + version_ids + ) + return self._get_action_items_for_contexts( + project_name, + version_context_by_id, + repre_context_by_id + ) + + def get_representations_action_items( + self, project_name, representation_ids + ): + """Get action items for given representation ids. + + Args: + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + + Returns: + list[ActionItem]: List of action items. + """ + + ( + product_context_by_id, + repre_context_by_id + ) = self._contexts_for_representations( + project_name, + representation_ids + ) + return self._get_action_items_for_contexts( + project_name, + product_context_by_id, + repre_context_by_id + ) + + def trigger_action_item( + self, + identifier, + options, + project_name, + version_ids, + representation_ids + ): + """Trigger action by identifier. + + Triggers the action by identifier for given contexts. + + Triggers events "load.started" and "load.finished". Finished event + also contains "error_info" key with error information if any + happened. + + Args: + identifier (str): Loader identifier. + options (dict[str, Any]): Loader option values. + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + representation_ids (Iterable[str]): Representation ids. + """ + + event_data = { + "identifier": identifier, + "id": uuid.uuid4().hex, + } + self._controller.emit_event( + "load.started", + event_data, + ACTIONS_MODEL_SENDER, + ) + loader = self._get_loader_by_identifier(project_name, identifier) + if representation_ids is not None: + error_info = self._trigger_representation_loader( + loader, + options, + project_name, + representation_ids, + ) + elif version_ids is not None: + error_info = self._trigger_version_loader( + loader, + options, + project_name, + version_ids, + ) + else: + raise NotImplementedError( + "Invalid arguments to trigger action item") + + event_data["error_info"] = error_info + self._controller.emit_event( + "load.finished", + event_data, + ACTIONS_MODEL_SENDER, + ) + + def _get_current_context_project(self): + """Get current context project name. + + The value is based on controller (host) and cached. + + Returns: + Union[str, None]: Current context project. + """ + + if self._current_context_project is NOT_SET: + context = self._controller.get_current_context() + self._current_context_project = context["project_name"] + return self._current_context_project + + def _get_action_label(self, loader, representation=None): + """Pull label info from loader class. + + Args: + loader (LoaderPlugin): Plugin class. + representation (Optional[dict[str, Any]]): Representation data. + + Returns: + str: Action label. + """ + + label = getattr(loader, "label", None) + if label is None: + label = loader.__name__ + if representation: + # Add the representation as suffix + label = "{} ({})".format(label, representation["name"]) + return label + + def _get_action_icon(self, loader): + """Pull icon info from loader class. + + Args: + loader (LoaderPlugin): Plugin class. + + Returns: + Union[dict[str, Any], None]: Icon definition based on + loader plugin. + """ + + # Support font-awesome icons using the `.icon` and `.color` + # attributes on plug-ins. + icon = getattr(loader, "icon", None) + if icon is not None and not isinstance(icon, dict): + icon = { + "type": "awesome-font", + "name": icon, + "color": getattr(loader, "color", None) or "white" + } + return icon + + def _get_action_tooltip(self, loader): + """Pull tooltip info from loader class. + + Args: + loader (LoaderPlugin): Plugin class. + + Returns: + str: Action tooltip. + """ + + # Add tooltip and statustip from Loader docstring + return inspect.getdoc(loader) + + def _filter_loaders_by_tool_name(self, project_name, loaders): + """Filter loaders by tool name. + + Tool names are based on OpenPype tools loader tool and library + loader tool. The new tool merged both into one tool and the difference + is based only on current project name. + + Args: + project_name (str): Project name. + loaders (list[LoaderPlugin]): List of loader plugins. + + Returns: + list[LoaderPlugin]: Filtered list of loader plugins. + """ + + # Keep filtering by tool name + # - if current context project name is same as project name we do + # expect the tool is used as OpenPype loader tool, otherwise + # as library loader tool. + if project_name == self._get_current_context_project(): + tool_name = "loader" + else: + tool_name = "library_loader" + filtered_loaders = [] + for loader in loaders: + tool_names = getattr(loader, "tool_names", None) + if ( + tool_names is None + or "*" in tool_names + or tool_name in tool_names + ): + filtered_loaders.append(loader) + return filtered_loaders + + def _create_loader_action_item( + self, + loader, + contexts, + project_name, + folder_ids=None, + product_ids=None, + version_ids=None, + representation_ids=None, + repre_name=None, + ): + label = self._get_action_label(loader) + if repre_name: + label = "{} ({})".format(label, repre_name) + return ActionItem( + get_loader_identifier(loader), + label=label, + icon=self._get_action_icon(loader), + tooltip=self._get_action_tooltip(loader), + options=loader.get_options(contexts), + order=loader.order, + project_name=project_name, + folder_ids=folder_ids, + product_ids=product_ids, + version_ids=version_ids, + representation_ids=representation_ids, + ) + + def _get_loaders(self, project_name): + """Loaders with loaded settings for a project. + + Questions: + Project name is required because of settings. Should we actually + pass in current project name instead of project name where + we want to show loaders for? + + Returns: + tuple[list[SubsetLoaderPlugin], list[LoaderPlugin]]: Discovered + loader plugins. + """ + + loaders_by_identifier_c = self._loaders_by_identifier[project_name] + product_loaders_c = self._product_loaders[project_name] + repre_loaders_c = self._repre_loaders[project_name] + if loaders_by_identifier_c.is_valid: + return product_loaders_c.get_data(), repre_loaders_c.get_data() + + # Get all representation->loader combinations available for the + # index under the cursor, so we can list the user the options. + available_loaders = self._filter_loaders_by_tool_name( + project_name, discover_loader_plugins(project_name) + ) + + repre_loaders = [] + product_loaders = [] + loaders_by_identifier = {} + for loader_cls in available_loaders: + if not loader_cls.enabled: + continue + + identifier = get_loader_identifier(loader_cls) + loaders_by_identifier[identifier] = loader_cls + if issubclass(loader_cls, SubsetLoaderPlugin): + product_loaders.append(loader_cls) + else: + repre_loaders.append(loader_cls) + + loaders_by_identifier_c.update_data(loaders_by_identifier) + product_loaders_c.update_data(product_loaders) + repre_loaders_c.update_data(repre_loaders) + return product_loaders, repre_loaders + + def _get_loader_by_identifier(self, project_name, identifier): + if not self._loaders_by_identifier[project_name].is_valid: + self._get_loaders(project_name) + loaders_by_identifier_c = self._loaders_by_identifier[project_name] + loaders_by_identifier = loaders_by_identifier_c.get_data() + return loaders_by_identifier.get(identifier) + + def _actions_sorter(self, action_item): + """Sort the Loaders by their order and then their name. + + Returns: + tuple[int, str]: Sort keys. + """ + + return action_item.order, action_item.label + + def _get_version_docs(self, project_name, version_ids): + """Get version documents for given version ids. + + This function also handles hero versions and copies data from + source version to it. + + Todos: + Remove this function when this is completely rewritten to + use AYON calls. + """ + + version_docs = list(get_versions( + project_name, version_ids=version_ids, hero=True + )) + hero_versions_by_src_id = collections.defaultdict(list) + src_hero_version = set() + for version_doc in version_docs: + if version_doc["type"] != "hero": + continue + version_id = "" + src_hero_version.add(version_id) + hero_versions_by_src_id[version_id].append(version_doc) + + src_versions = [] + if src_hero_version: + src_versions = get_versions(project_name, version_ids=version_ids) + for src_version in src_versions: + src_version_id = src_version["_id"] + for hero_version in hero_versions_by_src_id[src_version_id]: + hero_version["data"] = copy.deepcopy(src_version["data"]) + + return version_docs + + def _contexts_for_versions(self, project_name, version_ids): + """Get contexts for given version ids. + + Prepare version contexts for 'SubsetLoaderPlugin' and representation + contexts for 'LoaderPlugin' for all children representations of + given versions. + + This method is very similar to '_contexts_for_representations' but the + queries of documents are called in a different order. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and + representation contexts. + """ + + # TODO fix hero version + version_context_by_id = {} + repre_context_by_id = {} + if not project_name and not version_ids: + return version_context_by_id, repre_context_by_id + + version_docs = self._get_version_docs(project_name, version_ids) + version_docs_by_id = {} + version_docs_by_product_id = collections.defaultdict(list) + for version_doc in version_docs: + version_id = version_doc["_id"] + product_id = version_doc["parent"] + version_docs_by_id[version_id] = version_doc + version_docs_by_product_id[product_id].append(version_doc) + + _product_ids = set(version_docs_by_product_id.keys()) + _product_docs = get_subsets(project_name, subset_ids=_product_ids) + product_docs_by_id = {p["_id"]: p for p in _product_docs} + + _folder_ids = {p["parent"] for p in product_docs_by_id.values()} + _folder_docs = get_assets(project_name, asset_ids=_folder_ids) + folder_docs_by_id = {f["_id"]: f for f in _folder_docs} + + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + + for version_doc in version_docs: + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + version_context_by_id[product_id] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + } + + repre_docs = get_representations( + project_name, version_ids=version_ids) + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + version_doc = version_docs_by_id[version_id] + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + + repre_context_by_id[repre_doc["_id"]] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + "representation": repre_doc, + } + + return version_context_by_id, repre_context_by_id + + def _contexts_for_representations(self, project_name, repre_ids): + """Get contexts for given representation ids. + + Prepare version contexts for 'SubsetLoaderPlugin' and representation + contexts for 'LoaderPlugin' for all children representations of + given versions. + + This method is very similar to '_contexts_for_versions' but the + queries of documents are called in a different order. + + Args: + project_name (str): Project name. + repre_ids (Iterable[str]): Representation ids. + + Returns: + tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and + representation contexts. + """ + + product_context_by_id = {} + repre_context_by_id = {} + if not project_name and not repre_ids: + return product_context_by_id, repre_context_by_id + + repre_docs = list(get_representations( + project_name, representation_ids=repre_ids + )) + version_ids = {r["parent"] for r in repre_docs} + version_docs = self._get_version_docs(project_name, version_ids) + version_docs_by_id = { + v["_id"]: v for v in version_docs + } + + product_ids = {v["parent"] for v in version_docs_by_id.values()} + product_docs = get_subsets(project_name, subset_ids=product_ids) + product_docs_by_id = { + p["_id"]: p for p in product_docs + } + + folder_ids = {p["parent"] for p in product_docs_by_id.values()} + folder_docs = get_assets(project_name, asset_ids=folder_ids) + folder_docs_by_id = { + f["_id"]: f for f in folder_docs + } + + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + + for product_id, product_doc in product_docs_by_id.items(): + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + product_context_by_id[product_id] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + } + + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + version_doc = version_docs_by_id[version_id] + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + + repre_context_by_id[repre_doc["_id"]] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + "representation": repre_doc, + } + return product_context_by_id, repre_context_by_id + + def _get_action_items_for_contexts( + self, + project_name, + version_context_by_id, + repre_context_by_id + ): + """Prepare action items based on contexts. + + Actions are prepared based on discovered loader plugins and contexts. + The context must be valid for the loader plugin. + + Args: + project_name (str): Project name. + version_context_by_id (dict[str, dict[str, Any]]): Version + contexts by version id. + repre_context_by_id (dict[str, dict[str, Any]]): Representation + """ + + action_items = [] + if not version_context_by_id and not repre_context_by_id: + return action_items + + product_loaders, repre_loaders = self._get_loaders(project_name) + + repre_contexts_by_name = collections.defaultdict(list) + for repre_context in repre_context_by_id.values(): + repre_name = repre_context["representation"]["name"] + repre_contexts_by_name[repre_name].append(repre_context) + + for loader in repre_loaders: + # # do not allow download whole repre, select specific repre + # if tools_lib.is_sync_loader(loader): + # continue + + for repre_name, repre_contexts in repre_contexts_by_name.items(): + filtered_repre_contexts = filter_repre_contexts_by_loader( + repre_contexts, loader) + if not filtered_repre_contexts: + continue + + repre_ids = set() + repre_version_ids = set() + repre_product_ids = set() + repre_folder_ids = set() + for repre_context in filtered_repre_contexts: + repre_ids.add(repre_context["representation"]["_id"]) + repre_product_ids.add(repre_context["subset"]["_id"]) + repre_version_ids.add(repre_context["version"]["_id"]) + repre_folder_ids.add(repre_context["asset"]["_id"]) + + item = self._create_loader_action_item( + loader, + repre_contexts, + project_name=project_name, + folder_ids=repre_folder_ids, + product_ids=repre_product_ids, + version_ids=repre_version_ids, + representation_ids=repre_ids, + repre_name=repre_name, + ) + action_items.append(item) + + # Subset Loaders. + version_ids = set(version_context_by_id.keys()) + product_folder_ids = set() + product_ids = set() + for product_context in version_context_by_id.values(): + product_ids.add(product_context["subset"]["_id"]) + product_folder_ids.add(product_context["asset"]["_id"]) + + version_contexts = list(version_context_by_id.values()) + for loader in product_loaders: + item = self._create_loader_action_item( + loader, + version_contexts, + project_name=project_name, + folder_ids=product_folder_ids, + product_ids=product_ids, + version_ids=version_ids, + ) + action_items.append(item) + + action_items.sort(key=self._actions_sorter) + return action_items + + def _trigger_version_loader( + self, + loader, + options, + project_name, + version_ids, + ): + """Trigger version loader. + + This triggers 'load' method of 'SubsetLoaderPlugin' for given version + ids. + + Note: + Even when the plugin is 'SubsetLoaderPlugin' it actually expects + versions and should be named 'VersionLoaderPlugin'. Because it + is planned to refactor load system and introduce + 'LoaderAction' plugins it is not relevant to change it + anymore. + + Args: + loader (SubsetLoaderPlugin): Loader plugin to use. + options (dict): Option values for loader. + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + """ + + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + + version_docs = self._get_version_docs(project_name, version_ids) + product_ids = {v["parent"] for v in version_docs} + product_docs = get_subsets(project_name, subset_ids=product_ids) + product_docs_by_id = {f["_id"]: f for f in product_docs} + folder_ids = {p["parent"] for p in product_docs_by_id.values()} + folder_docs = get_assets(project_name, asset_ids=folder_ids) + folder_docs_by_id = {f["_id"]: f for f in folder_docs} + product_contexts = [] + for version_doc in version_docs: + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + product_contexts.append({ + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + }) + + return self._load_products_by_loader( + loader, product_contexts, options + ) + + def _trigger_representation_loader( + self, + loader, + options, + project_name, + representation_ids, + ): + """Trigger representation loader. + + This triggers 'load' method of 'LoaderPlugin' for given representation + ids. For that are prepared contexts for each representation, with + all parent documents. + + Args: + loader (LoaderPlugin): Loader plugin to use. + options (dict): Option values for loader. + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + """ + + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + repre_docs = list(get_representations( + project_name, representation_ids=representation_ids + )) + version_ids = {r["parent"] for r in repre_docs} + version_docs = self._get_version_docs(project_name, version_ids) + version_docs_by_id = {v["_id"]: v for v in version_docs} + product_ids = {v["parent"] for v in version_docs_by_id.values()} + product_docs = get_subsets(project_name, subset_ids=product_ids) + product_docs_by_id = {p["_id"]: p for p in product_docs} + folder_ids = {p["parent"] for p in product_docs_by_id.values()} + folder_docs = get_assets(project_name, asset_ids=folder_ids) + folder_docs_by_id = {f["_id"]: f for f in folder_docs} + repre_contexts = [] + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + version_doc = version_docs_by_id[version_id] + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + repre_contexts.append({ + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + "representation": repre_doc, + }) + + return self._load_representations_by_loader( + loader, repre_contexts, options + ) + + def _load_representations_by_loader(self, loader, repre_contexts, options): + """Loops through list of repre_contexts and loads them with one loader + + Args: + loader (LoaderPlugin): Loader plugin to use. + repre_contexts (list[dict]): Full info about selected + representations, containing repre, version, subset, asset and + project documents. + options (dict): Data from options. + """ + + error_info = [] + for repre_context in repre_contexts: + version_doc = repre_context["version"] + if version_doc["type"] == "hero_version": + version_name = "Hero" + else: + version_name = version_doc.get("name") + try: + load_with_repre_context( + loader, + repre_context, + options=options + ) + + except IncompatibleLoaderError as exc: + print(exc) + error_info.append(( + "Incompatible Loader", + None, + repre_context["representation"]["name"], + repre_context["subset"]["name"], + version_name + )) + + except Exception as exc: + formatted_traceback = None + if not isinstance(exc, LoadError): + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join(traceback.format_exception( + exc_type, exc_value, exc_traceback + )) + + error_info.append(( + str(exc), + formatted_traceback, + repre_context["representation"]["name"], + repre_context["subset"]["name"], + version_name + )) + return error_info + + def _load_products_by_loader(self, loader, version_contexts, options): + """Triggers load with SubsetLoader type of loaders. + + Warning: + Plugin is named 'SubsetLoader' but version is passed to context + too. + + Args: + loader (SubsetLoder): Loader used to load. + version_contexts (list[dict[str, Any]]): For context for each + version. + options (dict[str, Any]): Options for loader that user could fill. + """ + + error_info = [] + if loader.is_multiple_contexts_compatible: + subset_names = [] + for context in version_contexts: + subset_name = context.get("subset", {}).get("name") or "N/A" + subset_names.append(subset_name) + try: + load_with_subset_contexts( + loader, + version_contexts, + options=options + ) + + except Exception as exc: + formatted_traceback = None + if not isinstance(exc, LoadError): + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join(traceback.format_exception( + exc_type, exc_value, exc_traceback + )) + error_info.append(( + str(exc), + formatted_traceback, + None, + ", ".join(subset_names), + None + )) + else: + for version_context in version_contexts: + subset_name = ( + version_context.get("subset", {}).get("name") or "N/A" + ) + try: + load_with_subset_context( + loader, + version_context, + options=options + ) + + except Exception as exc: + formatted_traceback = None + if not isinstance(exc, LoadError): + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join( + traceback.format_exception( + exc_type, exc_value, exc_traceback + ) + ) + + error_info.append(( + str(exc), + formatted_traceback, + None, + subset_name, + None + )) + + return error_info diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py new file mode 100644 index 0000000000..33023cc164 --- /dev/null +++ b/openpype/tools/ayon_loader/models/products.py @@ -0,0 +1,682 @@ +import collections +import contextlib + +import arrow +import ayon_api +from ayon_api.operations import OperationsSession + +from openpype.style import get_default_entity_icon_color +from openpype.tools.ayon_utils.models import NestedCacheItem +from openpype.tools.ayon_loader.abstract import ( + ProductTypeItem, + ProductItem, + VersionItem, + RepreItem, +) + +PRODUCTS_MODEL_SENDER = "products.model" + + +def version_item_from_entity(version): + version_attribs = version["attrib"] + frame_start = version_attribs.get("frameStart") + frame_end = version_attribs.get("frameEnd") + handle_start = version_attribs.get("handleStart") + handle_end = version_attribs.get("handleEnd") + step = version_attribs.get("step") + comment = version_attribs.get("comment") + source = version_attribs.get("source") + + frame_range = None + duration = None + handles = None + if frame_start is not None and frame_end is not None: + # Remove superfluous zeros from numbers (3.0 -> 3) to improve + # readability for most frame ranges + frame_start = int(frame_start) + frame_end = int(frame_end) + frame_range = "{}-{}".format(frame_start, frame_end) + duration = frame_end - frame_start + 1 + + if handle_start is not None and handle_end is not None: + handles = "{}-{}".format(int(handle_start), int(handle_end)) + + # NOTE There is also 'updatedAt', should be used that instead? + # TODO skip conversion - converting to '%Y%m%dT%H%M%SZ' is because + # 'PrettyTimeDelegate' expects it + created_at = arrow.get(version["createdAt"]) + published_time = created_at.strftime("%Y%m%dT%H%M%SZ") + author = version["author"] + version_num = version["version"] + is_hero = version_num < 0 + + return VersionItem( + version_id=version["id"], + version=version_num, + is_hero=is_hero, + product_id=version["productId"], + thumbnail_id=version["thumbnailId"], + published_time=published_time, + author=author, + frame_range=frame_range, + duration=duration, + handles=handles, + step=step, + comment=comment, + source=source, + ) + + +def product_item_from_entity( + product_entity, + version_entities, + product_type_items_by_name, + folder_label, + product_in_scene, +): + product_attribs = product_entity["attrib"] + group = product_attribs.get("productGroup") + product_type = product_entity["productType"] + product_type_item = product_type_items_by_name[product_type] + product_type_icon = product_type_item.icon + + product_icon = { + "type": "awesome-font", + "name": "fa.file-o", + "color": get_default_entity_icon_color(), + } + version_items = { + version_entity["id"]: version_item_from_entity(version_entity) + for version_entity in version_entities + } + + return ProductItem( + product_id=product_entity["id"], + product_type=product_type, + product_name=product_entity["name"], + product_icon=product_icon, + product_type_icon=product_type_icon, + product_in_scene=product_in_scene, + group_name=group, + folder_id=product_entity["folderId"], + folder_label=folder_label, + version_items=version_items, + ) + + +def product_type_item_from_data(product_type_data): + # TODO implement icon implementation + # icon = product_type_data["icon"] + # color = product_type_data["color"] + icon = { + "type": "awesome-font", + "name": "fa.folder", + "color": "#0091B2", + } + # TODO implement checked logic + return ProductTypeItem(product_type_data["name"], icon, True) + + +class ProductsModel: + """Model for products, version and representation. + + All of the entities are product based. This model prepares data for UI + and caches it for faster access. + + Note: + Data are not used for actions model because that would require to + break OpenPype compatibility of 'LoaderPlugin's. + """ + + lifetime = 60 # In seconds (minute by default) + + def __init__(self, controller): + self._controller = controller + + # Mapping helpers + # NOTE - mapping must be cleaned up with cache cleanup + self._product_item_by_id = collections.defaultdict(dict) + self._version_item_by_id = collections.defaultdict(dict) + self._product_folder_ids_mapping = collections.defaultdict(dict) + + # Cache helpers + self._product_type_items_cache = NestedCacheItem( + levels=1, default_factory=list, lifetime=self.lifetime) + self._product_items_cache = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) + self._repre_items_cache = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) + + def reset(self): + """Reset model with all cached data.""" + + self._product_item_by_id.clear() + self._version_item_by_id.clear() + self._product_folder_ids_mapping.clear() + + self._product_type_items_cache.reset() + self._product_items_cache.reset() + self._repre_items_cache.reset() + + def get_product_type_items(self, project_name): + """Product type items for project. + + Args: + project_name (str): Project name. + + Returns: + list[ProductTypeItem]: Product type items. + """ + + cache = self._product_type_items_cache[project_name] + if not cache.is_valid: + product_types = ayon_api.get_project_product_types(project_name) + cache.update_data([ + product_type_item_from_data(product_type) + for product_type in product_types + ]) + return cache.get_data() + + def get_product_items(self, project_name, folder_ids, sender): + """Product items with versions for project and folder ids. + + Product items also contain version items. They're directly connected + to product items in the UI and the separation is not needed. + + Args: + project_name (Union[str, None]): Project name. + folder_ids (Iterable[str]): Folder ids. + sender (Union[str, None]): Who triggered the method. + + Returns: + list[ProductItem]: Product items. + """ + + if not project_name or not folder_ids: + return [] + + project_cache = self._product_items_cache[project_name] + output = [] + folder_ids_to_update = set() + for folder_id in folder_ids: + cache = project_cache[folder_id] + if cache.is_valid: + output.extend(cache.get_data().values()) + else: + folder_ids_to_update.add(folder_id) + + self._refresh_product_items( + project_name, folder_ids_to_update, sender) + + for folder_id in folder_ids_to_update: + cache = project_cache[folder_id] + output.extend(cache.get_data().values()) + return output + + def get_product_item(self, project_name, product_id): + """Get product item based on passed product id. + + This method is using cached items, but if cache is not valid it also + can query the item. + + Args: + project_name (Union[str, None]): Where to look for product. + product_id (Union[str, None]): Product id to receive. + + Returns: + Union[ProductItem, None]: Product item or 'None' if not found. + """ + + if not any((project_name, product_id)): + return None + + product_items_by_id = self._product_item_by_id[project_name] + product_item = product_items_by_id.get(product_id) + if product_item is not None: + return product_item + for product_item in self._query_product_items_by_ids( + project_name, product_ids=[product_id] + ).values(): + return product_item + + def get_product_ids_by_repre_ids(self, project_name, repre_ids): + """Get product ids based on passed representation ids. + + Args: + project_name (str): Where to look for representations. + repre_ids (Iterable[str]): Representation ids. + + Returns: + set[str]: Product ids for passed representation ids. + """ + + # TODO look out how to use single server call + if not repre_ids: + return set() + repres = ayon_api.get_representations( + project_name, repre_ids, fields=["versionId"] + ) + version_ids = {repre["versionId"] for repre in repres} + if not version_ids: + return set() + versions = ayon_api.get_versions( + project_name, version_ids=version_ids, fields=["productId"] + ) + return {v["productId"] for v in versions} + + def get_repre_items(self, project_name, version_ids, sender): + """Get representation items for passed version ids. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + sender (Union[str, None]): Who triggered the method. + + Returns: + list[RepreItem]: Representation items. + """ + + output = [] + if not any((project_name, version_ids)): + return output + + invalid_version_ids = set() + project_cache = self._repre_items_cache[project_name] + for version_id in version_ids: + version_cache = project_cache[version_id] + if version_cache.is_valid: + output.extend(version_cache.get_data().values()) + else: + invalid_version_ids.add(version_id) + + if invalid_version_ids: + self.refresh_representation_items( + project_name, invalid_version_ids, sender + ) + + for version_id in invalid_version_ids: + version_cache = project_cache[version_id] + output.extend(version_cache.get_data().values()) + + return output + + def change_products_group(self, project_name, product_ids, group_name): + """Change group name for passed product ids. + + Group name is stored in 'attrib' of product entity and is used in UI + to group items. + + Method triggers "products.group.changed" event with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "product_ids": product_ids, + "group_name": group_name + } + + Args: + project_name (str): Project name. + product_ids (Iterable[str]): Product ids to change group name for. + group_name (str): Group name to set. + """ + + if not product_ids: + return + + product_items = self._get_product_items_by_id( + project_name, product_ids + ) + if not product_items: + return + + session = OperationsSession() + folder_ids = set() + for product_item in product_items.values(): + session.update_entity( + project_name, + "product", + product_item.product_id, + {"attrib": {"productGroup": group_name}} + ) + folder_ids.add(product_item.folder_id) + product_item.group_name = group_name + + session.commit() + self._controller.emit_event( + "products.group.changed", + { + "project_name": project_name, + "folder_ids": folder_ids, + "product_ids": product_ids, + "group_name": group_name, + }, + PRODUCTS_MODEL_SENDER + ) + + def _get_product_items_by_id(self, project_name, product_ids): + product_item_by_id = self._product_item_by_id[project_name] + missing_product_ids = set() + output = {} + for product_id in product_ids: + product_item = product_item_by_id.get(product_id) + if product_item is not None: + output[product_id] = product_item + else: + missing_product_ids.add(product_id) + + output.update( + self._query_product_items_by_ids( + project_name, missing_product_ids + ) + ) + return output + + def _get_version_items_by_id(self, project_name, version_ids): + version_item_by_id = self._version_item_by_id[project_name] + missing_version_ids = set() + output = {} + for version_id in version_ids: + version_item = version_item_by_id.get(version_id) + if version_item is not None: + output[version_id] = version_item + else: + missing_version_ids.add(version_id) + + output.update( + self._query_version_items_by_ids( + project_name, missing_version_ids + ) + ) + return output + + def _create_product_items( + self, + project_name, + products, + versions, + folder_items=None, + product_type_items=None, + ): + if folder_items is None: + folder_items = self._controller.get_folder_items(project_name) + + if product_type_items is None: + product_type_items = self.get_product_type_items(project_name) + + loaded_product_ids = self._controller.get_loaded_product_ids() + + versions_by_product_id = collections.defaultdict(list) + for version in versions: + versions_by_product_id[version["productId"]].append(version) + product_type_items_by_name = { + product_type_item.name: product_type_item + for product_type_item in product_type_items + } + output = {} + for product in products: + product_id = product["id"] + folder_id = product["folderId"] + folder_item = folder_items.get(folder_id) + if not folder_item: + continue + versions = versions_by_product_id[product_id] + if not versions: + continue + product_item = product_item_from_entity( + product, + versions, + product_type_items_by_name, + folder_item.label, + product_id in loaded_product_ids, + ) + output[product_id] = product_item + return output + + def _query_product_items_by_ids( + self, + project_name, + folder_ids=None, + product_ids=None, + folder_items=None + ): + """Query product items. + + This method does get from, or store to, cache attributes. + + One of 'product_ids' or 'folder_ids' must be passed to the method. + + Args: + project_name (str): Project name. + folder_ids (Optional[Iterable[str]]): Folder ids under which are + products. + product_ids (Optional[Iterable[str]]): Product ids to use. + folder_items (Optional[Dict[str, FolderItem]]): Prepared folder + items from controller. + + Returns: + dict[str, ProductItem]: Product items by product id. + """ + + if not folder_ids and not product_ids: + return {} + + kwargs = {} + if folder_ids is not None: + kwargs["folder_ids"] = folder_ids + + if product_ids is not None: + kwargs["product_ids"] = product_ids + + products = list(ayon_api.get_products(project_name, **kwargs)) + product_ids = {product["id"] for product in products} + + versions = ayon_api.get_versions( + project_name, product_ids=product_ids + ) + + return self._create_product_items( + project_name, products, versions, folder_items=folder_items + ) + + def _query_version_items_by_ids(self, project_name, version_ids): + versions = list(ayon_api.get_versions( + project_name, version_ids=version_ids + )) + product_ids = {version["productId"] for version in versions} + products = list(ayon_api.get_products( + project_name, product_ids=product_ids + )) + product_items = self._create_product_items( + project_name, products, versions + ) + version_items = {} + for product_item in product_items.values(): + version_items.update(product_item.version_items) + return version_items + + def _clear_product_version_items(self, project_name, folder_ids): + """Clear product and version items from memory. + + When products are re-queried for a folders, the old product and version + items in '_product_item_by_id' and '_version_item_by_id' should + be cleaned up from memory. And mapping in stored in + '_product_folder_ids_mapping' is not relevant either. + + Args: + project_name (str): Name of project. + folder_ids (Iterable[str]): Folder ids which are being refreshed. + """ + + project_mapping = self._product_folder_ids_mapping[project_name] + if not project_mapping: + return + + product_item_by_id = self._product_item_by_id[project_name] + version_item_by_id = self._version_item_by_id[project_name] + for folder_id in folder_ids: + product_ids = project_mapping.pop(folder_id, None) + if not product_ids: + continue + + for product_id in product_ids: + product_item = product_item_by_id.pop(product_id, None) + if product_item is None: + continue + for version_item in product_item.version_items.values(): + version_item_by_id.pop(version_item.version_id, None) + + def _refresh_product_items(self, project_name, folder_ids, sender): + """Refresh product items and store them in cache. + + Args: + project_name (str): Name of project. + folder_ids (Iterable[str]): Folder ids which are being refreshed. + sender (Union[str, None]): Who triggered the refresh. + """ + + if not project_name or not folder_ids: + return + + self._clear_product_version_items(project_name, folder_ids) + + project_mapping = self._product_folder_ids_mapping[project_name] + product_item_by_id = self._product_item_by_id[project_name] + version_item_by_id = self._version_item_by_id[project_name] + + for folder_id in folder_ids: + project_mapping[folder_id] = set() + + with self._product_refresh_event_manager( + project_name, folder_ids, sender + ): + folder_items = self._controller.get_folder_items(project_name) + items_by_folder_id = { + folder_id: {} + for folder_id in folder_ids + } + product_items_by_id = self._query_product_items_by_ids( + project_name, + folder_ids=folder_ids, + folder_items=folder_items + ) + for product_id, product_item in product_items_by_id.items(): + folder_id = product_item.folder_id + items_by_folder_id[product_item.folder_id][product_id] = ( + product_item + ) + + project_mapping[folder_id].add(product_id) + product_item_by_id[product_id] = product_item + for version_id, version_item in ( + product_item.version_items.items() + ): + version_item_by_id[version_id] = version_item + + project_cache = self._product_items_cache[project_name] + for folder_id, product_items in items_by_folder_id.items(): + project_cache[folder_id].update_data(product_items) + + @contextlib.contextmanager + def _product_refresh_event_manager( + self, project_name, folder_ids, sender + ): + self._controller.emit_event( + "products.refresh.started", + { + "project_name": project_name, + "folder_ids": folder_ids, + "sender": sender, + }, + PRODUCTS_MODEL_SENDER + ) + try: + yield + + finally: + self._controller.emit_event( + "products.refresh.finished", + { + "project_name": project_name, + "folder_ids": folder_ids, + "sender": sender, + }, + PRODUCTS_MODEL_SENDER + ) + + def refresh_representation_items( + self, project_name, version_ids, sender + ): + if not any((project_name, version_ids)): + return + self._controller.emit_event( + "model.representations.refresh.started", + { + "project_name": project_name, + "version_ids": version_ids, + "sender": sender, + }, + PRODUCTS_MODEL_SENDER + ) + failed = False + try: + self._refresh_representation_items(project_name, version_ids) + except Exception: + # TODO add more information about failed refresh + failed = True + + self._controller.emit_event( + "model.representations.refresh.finished", + { + "project_name": project_name, + "version_ids": version_ids, + "sender": sender, + "failed": failed, + }, + PRODUCTS_MODEL_SENDER + ) + + def _refresh_representation_items(self, project_name, version_ids): + representations = list(ayon_api.get_representations( + project_name, + version_ids=version_ids, + fields=["id", "name", "versionId"] + )) + + version_items_by_id = self._get_version_items_by_id( + project_name, version_ids + ) + product_ids = { + version_item.product_id + for version_item in version_items_by_id.values() + } + product_items_by_id = self._get_product_items_by_id( + project_name, product_ids + ) + repre_icon = { + "type": "awesome-font", + "name": "fa.file-o", + "color": get_default_entity_icon_color(), + } + repre_items_by_version_id = collections.defaultdict(dict) + for representation in representations: + version_id = representation["versionId"] + version_item = version_items_by_id.get(version_id) + if version_item is None: + continue + product_item = product_items_by_id.get(version_item.product_id) + if product_item is None: + continue + repre_id = representation["id"] + repre_item = RepreItem( + repre_id, + representation["name"], + repre_icon, + product_item.product_name, + product_item.folder_label, + ) + repre_items_by_version_id[version_id][repre_id] = repre_item + + project_cache = self._repre_items_cache[project_name] + for version_id, repre_items in repre_items_by_version_id.items(): + version_cache = project_cache[version_id] + version_cache.update_data(repre_items) diff --git a/openpype/tools/ayon_loader/models/selection.py b/openpype/tools/ayon_loader/models/selection.py new file mode 100644 index 0000000000..326ff835f6 --- /dev/null +++ b/openpype/tools/ayon_loader/models/selection.py @@ -0,0 +1,85 @@ +class SelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.project.changed" + - "selection.folders.changed" + - "selection.versions.changed" + """ + + event_source = "selection.model" + + def __init__(self, controller): + self._controller = controller + + self._project_name = None + self._folder_ids = set() + self._version_ids = set() + self._representation_ids = set() + + def get_selected_project_name(self): + return self._project_name + + def set_selected_project(self, project_name): + if self._project_name == project_name: + return + + self._project_name = project_name + self._controller.emit_event( + "selection.project.changed", + {"project_name": self._project_name}, + self.event_source + ) + + def get_selected_folder_ids(self): + return self._folder_ids + + def set_selected_folders(self, folder_ids): + if folder_ids == self._folder_ids: + return + + self._folder_ids = folder_ids + self._controller.emit_event( + "selection.folders.changed", + { + "project_name": self._project_name, + "folder_ids": folder_ids, + }, + self.event_source + ) + + def get_selected_version_ids(self): + return self._version_ids + + def set_selected_versions(self, version_ids): + if version_ids == self._version_ids: + return + + self._version_ids = version_ids + self._controller.emit_event( + "selection.versions.changed", + { + "project_name": self._project_name, + "folder_ids": self._folder_ids, + "version_ids": self._version_ids, + }, + self.event_source + ) + + def get_selected_representation_ids(self): + return self._representation_ids + + def set_selected_representations(self, repre_ids): + if repre_ids == self._representation_ids: + return + + self._representation_ids = repre_ids + self._controller.emit_event( + "selection.representations.changed", + { + "project_name": self._project_name, + "folder_ids": self._folder_ids, + "version_ids": self._version_ids, + "representation_ids": self._representation_ids, + } + ) diff --git a/openpype/tools/ayon_loader/ui/__init__.py b/openpype/tools/ayon_loader/ui/__init__.py new file mode 100644 index 0000000000..41e4418641 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/__init__.py @@ -0,0 +1,6 @@ +from .window import LoaderWindow + + +__all__ = ( + "LoaderWindow", +) diff --git a/openpype/tools/ayon_loader/ui/actions_utils.py b/openpype/tools/ayon_loader/ui/actions_utils.py new file mode 100644 index 0000000000..a269b643dc --- /dev/null +++ b/openpype/tools/ayon_loader/ui/actions_utils.py @@ -0,0 +1,118 @@ +import uuid + +from qtpy import QtWidgets, QtGui +import qtawesome + +from openpype.lib.attribute_definitions import AbstractAttrDef +from openpype.tools.attribute_defs import AttributeDefinitionsDialog +from openpype.tools.utils.widgets import ( + OptionalMenu, + OptionalAction, + OptionDialog, +) +from openpype.tools.ayon_utils.widgets import get_qt_icon + + +def show_actions_menu(action_items, global_point, one_item_selected, parent): + selected_action_item = None + selected_options = None + + if not action_items: + menu = QtWidgets.QMenu(parent) + action = _get_no_loader_action(menu, one_item_selected) + menu.addAction(action) + menu.exec_(global_point) + return selected_action_item, selected_options + + menu = OptionalMenu(parent) + + action_items_by_id = {} + for action_item in action_items: + item_id = uuid.uuid4().hex + action_items_by_id[item_id] = action_item + item_options = action_item.options + icon = get_qt_icon(action_item.icon) + use_option = bool(item_options) + action = OptionalAction( + action_item.label, + icon, + use_option, + menu + ) + if use_option: + # Add option box tip + action.set_option_tip(item_options) + + tip = action_item.tooltip + if tip: + action.setToolTip(tip) + action.setStatusTip(tip) + + action.setData(item_id) + + menu.addAction(action) + + action = menu.exec_(global_point) + if action is not None: + item_id = action.data() + selected_action_item = action_items_by_id.get(item_id) + + if selected_action_item is not None: + selected_options = _get_options(action, selected_action_item, parent) + + return selected_action_item, selected_options + + +def _get_options(action, action_item, parent): + """Provides dialog to select value from loader provided options. + + Loader can provide static or dynamically created options based on + AttributeDefinitions, and for backwards compatibility qargparse. + + Args: + action (OptionalAction) - Action object in menu. + action_item (ActionItem) - Action item with context information. + parent (QtCore.QObject) - Parent object for dialog. + + Returns: + Union[dict[str, Any], None]: Selected value from attributes or + 'None' if dialog was cancelled. + """ + + # Pop option dialog + options = action_item.options + if not getattr(action, "optioned", False) or not options: + return {} + + if isinstance(options[0], AbstractAttrDef): + qargparse_options = False + dialog = AttributeDefinitionsDialog(options, parent) + else: + qargparse_options = True + dialog = OptionDialog(parent) + dialog.create(options) + + dialog.setWindowTitle(action.label + " Options") + + if not dialog.exec_(): + return None + + # Get option + if qargparse_options: + return dialog.parse() + return dialog.get_values() + + +def _get_no_loader_action(menu, one_item_selected): + """Creates dummy no loader option in 'menu'""" + + if one_item_selected: + submsg = "this version." + else: + submsg = "your selection." + msg = "No compatible loaders for {}".format(submsg) + icon = qtawesome.icon( + "fa.exclamation", + color=QtGui.QColor(255, 51, 0) + ) + return QtWidgets.QAction(icon, ("*" + msg), menu) diff --git a/openpype/tools/ayon_loader/ui/folders_widget.py b/openpype/tools/ayon_loader/ui/folders_widget.py new file mode 100644 index 0000000000..53351f76d9 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/folders_widget.py @@ -0,0 +1,407 @@ +import qtpy +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.tools.utils import ( + RecursiveSortFilterProxyModel, + DeselectableTreeView, +) +from openpype.style import get_objected_colors + +from openpype.tools.ayon_utils.widgets import ( + FoldersModel, + FOLDERS_MODEL_SENDER_NAME, +) +from openpype.tools.ayon_utils.widgets.folders_widget import FOLDER_ID_ROLE + +if qtpy.API == "pyside": + from PySide.QtGui import QStyleOptionViewItemV4 +elif qtpy.API == "pyqt4": + from PyQt4.QtGui import QStyleOptionViewItemV4 + +UNDERLINE_COLORS_ROLE = QtCore.Qt.UserRole + 50 + + +class UnderlinesFolderDelegate(QtWidgets.QItemDelegate): + """Item delegate drawing bars under folder label. + + This is used in loader tool. Multiselection of folders + may group products by name under colored groups. Selected color groups are + then propagated back to selected folders as underlines. + """ + bar_height = 3 + + def __init__(self, *args, **kwargs): + super(UnderlinesFolderDelegate, self).__init__(*args, **kwargs) + colors = get_objected_colors("loader", "asset-view") + self._selected_color = colors["selected"].get_qcolor() + self._hover_color = colors["hover"].get_qcolor() + self._selected_hover_color = colors["selected-hover"].get_qcolor() + + def sizeHint(self, option, index): + """Add bar height to size hint.""" + result = super(UnderlinesFolderDelegate, self).sizeHint(option, index) + height = result.height() + result.setHeight(height + self.bar_height) + + return result + + def paint(self, painter, option, index): + """Replicate painting of an item and draw color bars if needed.""" + # Qt4 compat + if qtpy.API in ("pyside", "pyqt4"): + option = QStyleOptionViewItemV4(option) + + painter.save() + + item_rect = QtCore.QRect(option.rect) + item_rect.setHeight(option.rect.height() - self.bar_height) + + subset_colors = index.data(UNDERLINE_COLORS_ROLE) or [] + + subset_colors_width = 0 + if subset_colors: + subset_colors_width = option.rect.width() / len(subset_colors) + + subset_rects = [] + counter = 0 + for subset_c in subset_colors: + new_color = None + new_rect = None + if subset_c: + new_color = QtGui.QColor(subset_c) + + new_rect = QtCore.QRect( + option.rect.left() + (counter * subset_colors_width), + option.rect.top() + ( + option.rect.height() - self.bar_height + ), + subset_colors_width, + self.bar_height + ) + subset_rects.append((new_color, new_rect)) + counter += 1 + + # Background + if option.state & QtWidgets.QStyle.State_Selected: + if len(subset_colors) == 0: + item_rect.setTop(item_rect.top() + (self.bar_height / 2)) + + if option.state & QtWidgets.QStyle.State_MouseOver: + bg_color = self._selected_hover_color + else: + bg_color = self._selected_color + else: + item_rect.setTop(item_rect.top() + (self.bar_height / 2)) + if option.state & QtWidgets.QStyle.State_MouseOver: + bg_color = self._hover_color + else: + bg_color = QtGui.QColor() + bg_color.setAlpha(0) + + # When not needed to do a rounded corners (easier and without + # painter restore): + painter.fillRect( + option.rect, + QtGui.QBrush(bg_color) + ) + + if option.state & QtWidgets.QStyle.State_Selected: + for color, subset_rect in subset_rects: + if not color or not subset_rect: + continue + painter.fillRect(subset_rect, QtGui.QBrush(color)) + + # Icon + icon_index = index.model().index( + index.row(), index.column(), index.parent() + ) + # - Default icon_rect if not icon + icon_rect = QtCore.QRect( + item_rect.left(), + item_rect.top(), + # To make sure it's same size all the time + option.rect.height() - self.bar_height, + option.rect.height() - self.bar_height + ) + icon = index.model().data(icon_index, QtCore.Qt.DecorationRole) + + if icon: + mode = QtGui.QIcon.Normal + if not (option.state & QtWidgets.QStyle.State_Enabled): + mode = QtGui.QIcon.Disabled + elif option.state & QtWidgets.QStyle.State_Selected: + mode = QtGui.QIcon.Selected + + if isinstance(icon, QtGui.QPixmap): + icon = QtGui.QIcon(icon) + option.decorationSize = icon.size() / icon.devicePixelRatio() + + elif isinstance(icon, QtGui.QColor): + pixmap = QtGui.QPixmap(option.decorationSize) + pixmap.fill(icon) + icon = QtGui.QIcon(pixmap) + + elif isinstance(icon, QtGui.QImage): + icon = QtGui.QIcon(QtGui.QPixmap.fromImage(icon)) + option.decorationSize = icon.size() / icon.devicePixelRatio() + + elif isinstance(icon, QtGui.QIcon): + state = QtGui.QIcon.Off + if option.state & QtWidgets.QStyle.State_Open: + state = QtGui.QIcon.On + actual_size = option.icon.actualSize( + option.decorationSize, mode, state + ) + option.decorationSize = QtCore.QSize( + min(option.decorationSize.width(), actual_size.width()), + min(option.decorationSize.height(), actual_size.height()) + ) + + state = QtGui.QIcon.Off + if option.state & QtWidgets.QStyle.State_Open: + state = QtGui.QIcon.On + + icon.paint( + painter, icon_rect, + QtCore.Qt.AlignLeft, mode, state + ) + + # Text + text_rect = QtCore.QRect( + icon_rect.left() + icon_rect.width() + 2, + item_rect.top(), + item_rect.width(), + item_rect.height() + ) + + painter.drawText( + text_rect, QtCore.Qt.AlignVCenter, + index.data(QtCore.Qt.DisplayRole) + ) + + painter.restore() + + +class LoaderFoldersModel(FoldersModel): + def __init__(self, *args, **kwargs): + super(LoaderFoldersModel, self).__init__(*args, **kwargs) + + self._colored_items = set() + + def _fill_item_data(self, item, folder_item): + """ + + Args: + item (QtGui.QStandardItem): Item to fill data. + folder_item (FolderItem): Folder item. + """ + + super(LoaderFoldersModel, self)._fill_item_data(item, folder_item) + + def set_merged_products_selection(self, items): + changes = { + folder_id: None + for folder_id in self._colored_items + } + + all_folder_ids = set() + for item in items: + folder_ids = item["folder_ids"] + all_folder_ids.update(folder_ids) + + for folder_id in all_folder_ids: + changes[folder_id] = [] + + for item in items: + item_color = item["color"] + item_folder_ids = item["folder_ids"] + for folder_id in all_folder_ids: + folder_color = ( + item_color + if folder_id in item_folder_ids + else None + ) + changes[folder_id].append(folder_color) + + for folder_id, color_value in changes.items(): + item = self._items_by_id.get(folder_id) + if item is not None: + item.setData(color_value, UNDERLINE_COLORS_ROLE) + + self._colored_items = all_folder_ids + + +class LoaderFoldersWidget(QtWidgets.QWidget): + """Folders widget. + + Widget that handles folders view, model and selection. + + Expected selection handling is disabled by default. If enabled, the + widget will handle the expected in predefined way. Widget is listening + to event 'expected_selection_changed' with expected event data below, + the same data must be available when called method + 'get_expected_selection_data' on controller. + + { + "folder": { + "current": bool, # Folder is what should be set now + "folder_id": Union[str, None], # Folder id that should be selected + }, + ... + } + + Selection is confirmed by calling method 'expected_folder_selected' on + controller. + + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + refreshed = QtCore.Signal() + + def __init__(self, controller, parent): + super(LoaderFoldersWidget, self).__init__(parent) + + folders_view = DeselectableTreeView(self) + folders_view.setHeaderHidden(True) + folders_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection) + + folders_model = LoaderFoldersModel(controller) + folders_proxy_model = RecursiveSortFilterProxyModel() + folders_proxy_model.setSourceModel(folders_model) + folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + + folders_label_delegate = UnderlinesFolderDelegate(folders_view) + + folders_view.setModel(folders_proxy_model) + folders_view.setItemDelegate(folders_label_delegate) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(folders_view, 1) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_selection_change, + ) + controller.register_event_callback( + "folders.refresh.finished", + self._on_folders_refresh_finished + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh + ) + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + selection_model = folders_view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + folders_model.refreshed.connect(self._on_model_refresh) + + self._controller = controller + self._folders_view = folders_view + self._folders_model = folders_model + self._folders_proxy_model = folders_proxy_model + self._folders_label_delegate = folders_label_delegate + + self._expected_selection = None + + def set_name_filter(self, name): + """Set filter of folder name. + + Args: + name (str): The string filter. + """ + + self._folders_proxy_model.setFilterFixedString(name) + + def set_merged_products_selection(self, items): + """ + + Args: + items (list[dict[str, Any]]): List of merged items with folder + ids. + """ + + self._folders_model.set_merged_products_selection(items) + + def refresh(self): + self._folders_model.refresh() + + def _on_project_selection_change(self, event): + project_name = event["project_name"] + self._set_project_name(project_name) + + def _set_project_name(self, project_name): + self._folders_model.set_project_name(project_name) + + def _clear(self): + self._folders_model.clear() + + def _on_folders_refresh_finished(self, event): + if event["sender"] != FOLDERS_MODEL_SENDER_NAME: + self._set_project_name(event["project_name"]) + + def _on_controller_refresh(self): + self._update_expected_selection() + + def _on_model_refresh(self): + if self._expected_selection: + self._set_expected_selection() + self._folders_proxy_model.sort(0) + self.refreshed.emit() + + def _get_selected_item_ids(self): + selection_model = self._folders_view.selectionModel() + item_ids = [] + for index in selection_model.selectedIndexes(): + item_id = index.data(FOLDER_ID_ROLE) + if item_id is not None: + item_ids.append(item_id) + return item_ids + + def _on_selection_change(self): + item_ids = self._get_selected_item_ids() + self._controller.set_selected_folders(item_ids) + + # Expected selection handling + def _on_expected_selection_change(self, event): + self._update_expected_selection(event.data) + + def _update_expected_selection(self, expected_data=None): + if expected_data is None: + expected_data = self._controller.get_expected_selection_data() + + folder_data = expected_data.get("folder") + if not folder_data or not folder_data["current"]: + return + + folder_id = folder_data["id"] + self._expected_selection = folder_id + if not self._folders_model.is_refreshing: + self._set_expected_selection() + + def _set_expected_selection(self): + folder_id = self._expected_selection + selected_ids = self._get_selected_item_ids() + self._expected_selection = None + skip_selection = ( + folder_id is None + or ( + folder_id in selected_ids + and len(selected_ids) == 1 + ) + ) + if not skip_selection: + index = self._folders_model.get_index_by_id(folder_id) + if index.isValid(): + proxy_index = self._folders_proxy_model.mapFromSource(index) + self._folders_view.setCurrentIndex(proxy_index) + self._controller.expected_folder_selected(folder_id) diff --git a/openpype/tools/ayon_loader/ui/info_widget.py b/openpype/tools/ayon_loader/ui/info_widget.py new file mode 100644 index 0000000000..b7d1b0811f --- /dev/null +++ b/openpype/tools/ayon_loader/ui/info_widget.py @@ -0,0 +1,141 @@ +import datetime + +from qtpy import QtWidgets + +from openpype.tools.utils.lib import format_version + + +class VersionTextEdit(QtWidgets.QTextEdit): + """QTextEdit that displays version specific information. + + This also overrides the context menu to add actions like copying + source path to clipboard or copying the raw data of the version + to clipboard. + + """ + def __init__(self, controller, parent): + super(VersionTextEdit, self).__init__(parent=parent) + + self._version_item = None + self._product_item = None + + self._controller = controller + + # Reset + self.set_current_item() + + def set_current_item(self, product_item=None, version_item=None): + """ + + Args: + product_item (Union[ProductItem, None]): Product item. + version_item (Union[VersionItem, None]): Version item to display. + """ + + self._product_item = product_item + self._version_item = version_item + + if version_item is None: + # Reset state to empty + self.setText("") + return + + version_label = format_version(abs(version_item.version)) + if version_item.version < 0: + version_label = "Hero version {}".format(version_label) + + # Define readable creation timestamp + created = version_item.published_time + created = datetime.datetime.strptime(created, "%Y%m%dT%H%M%SZ") + created = datetime.datetime.strftime(created, "%b %d %Y %H:%M") + + comment = version_item.comment or "No comment" + source = version_item.source or "No source" + + self.setHtml( + ( + "

{product_name}

" + "

{version_label}

" + "Comment
" + "{comment}

" + + "Created
" + "{created}

" + + "Source
" + "{source}" + ).format( + product_name=product_item.product_name, + version_label=version_label, + comment=comment, + created=created, + source=source, + ) + ) + + def contextMenuEvent(self, event): + """Context menu with additional actions""" + menu = self.createStandardContextMenu() + + # Add additional actions when any text, so we can assume + # the version is set. + source = None + if self._version_item is not None: + source = self._version_item.source + + if source: + menu.addSeparator() + action = QtWidgets.QAction( + "Copy source path to clipboard", menu + ) + action.triggered.connect(self._on_copy_source) + menu.addAction(action) + + menu.exec_(event.globalPos()) + + def _on_copy_source(self): + """Copy formatted source path to clipboard.""" + + source = self._version_item.source + if not source: + return + + filled_source = self._controller.fill_root_in_source(source) + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(filled_source) + + +class InfoWidget(QtWidgets.QWidget): + """A Widget that display information about a specific version""" + def __init__(self, controller, parent): + super(InfoWidget, self).__init__(parent=parent) + + label_widget = QtWidgets.QLabel("Version Info", self) + info_text_widget = VersionTextEdit(controller, self) + info_text_widget.setReadOnly(True) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(label_widget, 0) + layout.addWidget(info_text_widget, 1) + + self._controller = controller + + self._info_text_widget = info_text_widget + self._label_widget = label_widget + + def set_selected_version_info(self, project_name, items): + if not items or not project_name: + self._info_text_widget.set_current_item() + return + first_item = next(iter(items)) + product_item = self._controller.get_product_item( + project_name, + first_item["product_id"], + ) + version_id = first_item["version_id"] + version_item = None + if product_item is not None: + version_item = product_item.version_items.get(version_id) + + self._info_text_widget.set_current_item(product_item, version_item) diff --git a/openpype/tools/ayon_loader/ui/product_group_dialog.py b/openpype/tools/ayon_loader/ui/product_group_dialog.py new file mode 100644 index 0000000000..5737ce58a4 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/product_group_dialog.py @@ -0,0 +1,45 @@ +from qtpy import QtWidgets + +from openpype.tools.utils import PlaceholderLineEdit + + +class ProductGroupDialog(QtWidgets.QDialog): + def __init__(self, controller, parent): + super(ProductGroupDialog, self).__init__(parent) + self.setWindowTitle("Grouping products") + self.setMinimumWidth(250) + self.setModal(True) + + main_label = QtWidgets.QLabel("Group Name", self) + + group_name_input = PlaceholderLineEdit(self) + group_name_input.setPlaceholderText("Remain blank to ungroup..") + + group_btn = QtWidgets.QPushButton("Apply", self) + group_btn.setAutoDefault(True) + group_btn.setDefault(True) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(main_label, 0) + layout.addWidget(group_name_input, 0) + layout.addWidget(group_btn, 0) + + group_btn.clicked.connect(self._on_apply_click) + + self._project_name = None + self._product_ids = set() + + self._controller = controller + self._group_btn = group_btn + self._group_name_input = group_name_input + + def set_product_ids(self, project_name, product_ids): + self._project_name = project_name + self._product_ids = product_ids + + def _on_apply_click(self): + group_name = self._group_name_input.text().strip() or None + self._controller.change_products_group( + self._project_name, self._product_ids, group_name + ) + self.close() diff --git a/openpype/tools/ayon_loader/ui/product_types_widget.py b/openpype/tools/ayon_loader/ui/product_types_widget.py new file mode 100644 index 0000000000..a84a7ff846 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/product_types_widget.py @@ -0,0 +1,220 @@ +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.tools.ayon_utils.widgets import get_qt_icon + +PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1 + + +class ProductTypesQtModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() + filter_changed = QtCore.Signal() + + def __init__(self, controller): + super(ProductTypesQtModel, self).__init__() + self._controller = controller + + self._refreshing = False + self._bulk_change = False + self._items_by_name = {} + + def is_refreshing(self): + return self._refreshing + + def get_filter_info(self): + """Product types filtering info. + + Returns: + dict[str, bool]: Filtering value by product type name. False value + means to hide product type. + """ + + return { + name: item.checkState() == QtCore.Qt.Checked + for name, item in self._items_by_name.items() + } + + def refresh(self, project_name): + self._refreshing = True + product_type_items = self._controller.get_product_type_items( + project_name) + + items_to_remove = set(self._items_by_name.keys()) + new_items = [] + for product_type_item in product_type_items: + name = product_type_item.name + items_to_remove.discard(name) + item = self._items_by_name.get(product_type_item.name) + if item is None: + item = QtGui.QStandardItem(name) + item.setData(name, PRODUCT_TYPE_ROLE) + item.setEditable(False) + item.setCheckable(True) + new_items.append(item) + self._items_by_name[name] = item + + item.setCheckState( + QtCore.Qt.Checked + if product_type_item.checked + else QtCore.Qt.Unchecked + ) + icon = get_qt_icon(product_type_item.icon) + item.setData(icon, QtCore.Qt.DecorationRole) + + root_item = self.invisibleRootItem() + if new_items: + root_item.appendRows(new_items) + + for name in items_to_remove: + item = self._items_by_name.pop(name) + root_item.removeRow(item.row()) + + self._refreshing = False + self.refreshed.emit() + + def setData(self, index, value, role=None): + checkstate_changed = False + if role is None: + role = QtCore.Qt.EditRole + elif role == QtCore.Qt.CheckStateRole: + checkstate_changed = True + output = super(ProductTypesQtModel, self).setData(index, value, role) + if checkstate_changed and not self._bulk_change: + self.filter_changed.emit() + return output + + def change_state_for_all(self, checked): + if self._items_by_name: + self.change_states(checked, self._items_by_name.keys()) + + def change_states(self, checked, product_types): + product_types = set(product_types) + if not product_types: + return + + if checked is None: + state = None + elif checked: + state = QtCore.Qt.Checked + else: + state = QtCore.Qt.Unchecked + + self._bulk_change = True + + changed = False + for product_type in product_types: + item = self._items_by_name.get(product_type) + if item is None: + continue + new_state = state + item_checkstate = item.checkState() + if new_state is None: + if item_checkstate == QtCore.Qt.Checked: + new_state = QtCore.Qt.Unchecked + else: + new_state = QtCore.Qt.Checked + elif item_checkstate == new_state: + continue + changed = True + item.setCheckState(new_state) + + self._bulk_change = False + + if changed: + self.filter_changed.emit() + + +class ProductTypesView(QtWidgets.QListView): + filter_changed = QtCore.Signal() + + def __init__(self, controller, parent): + super(ProductTypesView, self).__init__(parent) + + self.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + self.setAlternatingRowColors(True) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + product_types_model = ProductTypesQtModel(controller) + product_types_proxy_model = QtCore.QSortFilterProxyModel() + product_types_proxy_model.setSourceModel(product_types_model) + + self.setModel(product_types_proxy_model) + + product_types_model.refreshed.connect(self._on_refresh_finished) + product_types_model.filter_changed.connect(self._on_filter_change) + self.customContextMenuRequested.connect(self._on_context_menu) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_change + ) + + self._controller = controller + + self._product_types_model = product_types_model + self._product_types_proxy_model = product_types_proxy_model + + def get_filter_info(self): + return self._product_types_model.get_filter_info() + + def _on_project_change(self, event): + project_name = event["project_name"] + self._product_types_model.refresh(project_name) + + def _on_refresh_finished(self): + self.filter_changed.emit() + + def _on_filter_change(self): + if not self._product_types_model.is_refreshing(): + self.filter_changed.emit() + + def _change_selection_state(self, checkstate): + selection_model = self.selectionModel() + product_types = { + index.data(PRODUCT_TYPE_ROLE) + for index in selection_model.selectedIndexes() + } + product_types.discard(None) + self._product_types_model.change_states(checkstate, product_types) + + def _on_enable_all(self): + self._product_types_model.change_state_for_all(True) + + def _on_disable_all(self): + self._product_types_model.change_state_for_all(False) + + def _on_context_menu(self, pos): + menu = QtWidgets.QMenu(self) + + # Add enable all action + action_check_all = QtWidgets.QAction(menu) + action_check_all.setText("Enable All") + action_check_all.triggered.connect(self._on_enable_all) + # Add disable all action + action_uncheck_all = QtWidgets.QAction(menu) + action_uncheck_all.setText("Disable All") + action_uncheck_all.triggered.connect(self._on_disable_all) + + menu.addAction(action_check_all) + menu.addAction(action_uncheck_all) + + # Get mouse position + global_pos = self.viewport().mapToGlobal(pos) + menu.exec_(global_pos) + + def event(self, event): + if event.type() == QtCore.QEvent.KeyPress: + if event.key() == QtCore.Qt.Key_Space: + self._change_selection_state(None) + return True + + if event.key() == QtCore.Qt.Key_Backspace: + self._change_selection_state(False) + return True + + if event.key() == QtCore.Qt.Key_Return: + self._change_selection_state(True) + return True + + return super(ProductTypesView, self).event(event) diff --git a/openpype/tools/ayon_loader/ui/products_delegates.py b/openpype/tools/ayon_loader/ui/products_delegates.py new file mode 100644 index 0000000000..6729468bfa --- /dev/null +++ b/openpype/tools/ayon_loader/ui/products_delegates.py @@ -0,0 +1,191 @@ +import numbers +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.tools.utils.lib import format_version + +from .products_model import ( + PRODUCT_ID_ROLE, + VERSION_NAME_EDIT_ROLE, + VERSION_ID_ROLE, + PRODUCT_IN_SCENE_ROLE, +) + + +class VersionComboBox(QtWidgets.QComboBox): + value_changed = QtCore.Signal(str) + + def __init__(self, product_id, parent): + super(VersionComboBox, self).__init__(parent) + self._product_id = product_id + self._items_by_id = {} + + self._current_id = None + + self.currentIndexChanged.connect(self._on_index_change) + + def update_versions(self, version_items, current_version_id): + model = self.model() + root_item = model.invisibleRootItem() + version_items = list(reversed(version_items)) + version_ids = [ + version_item.version_id + for version_item in version_items + ] + if current_version_id not in version_ids and version_ids: + current_version_id = version_ids[0] + self._current_id = current_version_id + + to_remove = set(self._items_by_id.keys()) - set(version_ids) + for item_id in to_remove: + item = self._items_by_id.pop(item_id) + root_item.removeRow(item.row()) + + for idx, version_item in enumerate(version_items): + version_id = version_item.version_id + + item = self._items_by_id.get(version_id) + if item is None: + label = format_version( + abs(version_item.version), version_item.is_hero + ) + item = QtGui.QStandardItem(label) + item.setData(version_id, QtCore.Qt.UserRole) + self._items_by_id[version_id] = item + + if item.row() != idx: + root_item.insertRow(idx, item) + + index = version_ids.index(current_version_id) + if self.currentIndex() != index: + self.setCurrentIndex(index) + + def _on_index_change(self): + idx = self.currentIndex() + value = self.itemData(idx) + if value == self._current_id: + return + self._current_id = value + self.value_changed.emit(self._product_id) + + +class VersionDelegate(QtWidgets.QStyledItemDelegate): + """A delegate that display version integer formatted as version string.""" + + version_changed = QtCore.Signal() + + def __init__(self, *args, **kwargs): + super(VersionDelegate, self).__init__(*args, **kwargs) + self._editor_by_product_id = {} + + def displayText(self, value, locale): + if not isinstance(value, numbers.Integral): + return "N/A" + return format_version(abs(value), value < 0) + + def paint(self, painter, option, index): + fg_color = index.data(QtCore.Qt.ForegroundRole) + if fg_color: + if isinstance(fg_color, QtGui.QBrush): + fg_color = fg_color.color() + elif isinstance(fg_color, QtGui.QColor): + pass + else: + fg_color = None + + if not fg_color: + return super(VersionDelegate, self).paint(painter, option, index) + + if option.widget: + style = option.widget.style() + else: + style = QtWidgets.QApplication.style() + + style.drawControl( + style.CE_ItemViewItem, option, painter, option.widget + ) + + painter.save() + + text = self.displayText( + index.data(QtCore.Qt.DisplayRole), option.locale + ) + pen = painter.pen() + pen.setColor(fg_color) + painter.setPen(pen) + + text_rect = style.subElementRect(style.SE_ItemViewItemText, option) + text_margin = style.proxy().pixelMetric( + style.PM_FocusFrameHMargin, option, option.widget + ) + 1 + + painter.drawText( + text_rect.adjusted(text_margin, 0, - text_margin, 0), + option.displayAlignment, + text + ) + + painter.restore() + + def createEditor(self, parent, option, index): + product_id = index.data(PRODUCT_ID_ROLE) + if not product_id: + return + + editor = VersionComboBox(product_id, parent) + self._editor_by_product_id[product_id] = editor + editor.value_changed.connect(self._on_editor_change) + + return editor + + def _on_editor_change(self, product_id): + editor = self._editor_by_product_id[product_id] + + # Update model data + self.commitData.emit(editor) + # Display model data + self.version_changed.emit() + + def setEditorData(self, editor, index): + editor.clear() + + # Current value of the index + versions = index.data(VERSION_NAME_EDIT_ROLE) or [] + version_id = index.data(VERSION_ID_ROLE) + editor.update_versions(versions, version_id) + + def setModelData(self, editor, model, index): + """Apply the integer version back in the model""" + + version_id = editor.itemData(editor.currentIndex()) + model.setData(index, version_id, VERSION_NAME_EDIT_ROLE) + + +class LoadedInSceneDelegate(QtWidgets.QStyledItemDelegate): + """Delegate for Loaded in Scene state columns. + + Shows "Yes" or "No" for 1 or 0 values, or "N/A" for other values. + Colorizes green or dark grey based on values. + """ + + def __init__(self, *args, **kwargs): + super(LoadedInSceneDelegate, self).__init__(*args, **kwargs) + self._colors = { + 1: QtGui.QColor(80, 170, 80), + 0: QtGui.QColor(90, 90, 90), + } + self._default_color = QtGui.QColor(90, 90, 90) + + def displayText(self, value, locale): + if value == 0: + return "No" + elif value == 1: + return "Yes" + return "N/A" + + def initStyleOption(self, option, index): + super(LoadedInSceneDelegate, self).initStyleOption(option, index) + + # Colorize based on value + value = index.data(PRODUCT_IN_SCENE_ROLE) + color = self._colors.get(value, self._default_color) + option.palette.setBrush(QtGui.QPalette.Text, color) diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py new file mode 100644 index 0000000000..741f15766b --- /dev/null +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -0,0 +1,590 @@ +import collections + +import qtawesome +from qtpy import QtGui, QtCore + +from openpype.style import get_default_entity_icon_color +from openpype.tools.ayon_utils.widgets import get_qt_icon + +PRODUCTS_MODEL_SENDER_NAME = "qt_products_model" + +GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 1 +MERGED_COLOR_ROLE = QtCore.Qt.UserRole + 2 +FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 3 +FOLDER_ID_ROLE = QtCore.Qt.UserRole + 4 +PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 5 +PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 6 +PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 7 +PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 8 +PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 9 +VERSION_ID_ROLE = QtCore.Qt.UserRole + 10 +VERSION_HERO_ROLE = QtCore.Qt.UserRole + 11 +VERSION_NAME_ROLE = QtCore.Qt.UserRole + 12 +VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 13 +VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 14 +VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 15 +VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 16 +VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 17 +VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 18 +VERSION_STEP_ROLE = QtCore.Qt.UserRole + 19 +VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 20 +VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 21 + + +class ProductsModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() + version_changed = QtCore.Signal() + column_labels = [ + "Product name", + "Product type", + "Folder", + "Version", + "Time", + "Author", + "Frames", + "Duration", + "Handles", + "Step", + "In scene", + "Availability", + ] + merged_items_colors = [ + ("#{0:02x}{1:02x}{2:02x}".format(*c), QtGui.QColor(*c)) + for c in [ + (55, 161, 222), # Light Blue + (231, 176, 0), # Yellow + (154, 13, 255), # Purple + (130, 184, 30), # Light Green + (211, 79, 63), # Light Red + (179, 181, 182), # Grey + (194, 57, 179), # Pink + (0, 120, 215), # Dark Blue + (0, 204, 106), # Dark Green + (247, 99, 12), # Orange + ] + ] + + version_col = column_labels.index("Version") + published_time_col = column_labels.index("Time") + folders_label_col = column_labels.index("Folder") + in_scene_col = column_labels.index("In scene") + + def __init__(self, controller): + super(ProductsModel, self).__init__() + self.setColumnCount(len(self.column_labels)) + for idx, label in enumerate(self.column_labels): + self.setHeaderData(idx, QtCore.Qt.Horizontal, label) + self._controller = controller + + # Variables to store 'QStandardItem' + self._items_by_id = {} + self._group_items_by_name = {} + self._merged_items_by_id = {} + + # product item objects (they have version information) + self._product_items_by_id = {} + self._grouping_enabled = True + self._reset_merge_color = False + self._color_iterator = self._color_iter() + self._group_icon = None + + self._last_project_name = None + self._last_folder_ids = [] + + def get_product_item_indexes(self): + return [ + item.index() + for item in self._items_by_id.values() + ] + + def get_product_item_by_id(self, product_id): + """ + + Args: + product_id (str): Product id. + + Returns: + Union[ProductItem, None]: Product item with version information. + """ + + return self._product_items_by_id.get(product_id) + + def set_enable_grouping(self, enable_grouping): + if enable_grouping is self._grouping_enabled: + return + self._grouping_enabled = enable_grouping + # Ignore change if groups are not available + self.refresh(self._last_project_name, self._last_folder_ids) + + def flags(self, index): + # Make the version column editable + if index.column() == self.version_col and index.data(PRODUCT_ID_ROLE): + return ( + QtCore.Qt.ItemIsEnabled + | QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsEditable + ) + if index.column() != 0: + index = self.index(index.row(), 0, index.parent()) + return super(ProductsModel, self).flags(index) + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + if not index.isValid(): + return None + + col = index.column() + if col == 0: + return super(ProductsModel, self).data(index, role) + + if role == QtCore.Qt.DecorationRole: + if col == 1: + role = PRODUCT_TYPE_ICON_ROLE + else: + return None + + if ( + role == VERSION_NAME_EDIT_ROLE + or (role == QtCore.Qt.EditRole and col == self.version_col) + ): + index = self.index(index.row(), 0, index.parent()) + product_id = index.data(PRODUCT_ID_ROLE) + product_item = self._product_items_by_id.get(product_id) + if product_item is None: + return None + return list(product_item.version_items.values()) + + if role == QtCore.Qt.EditRole: + return None + + if role == QtCore.Qt.DisplayRole: + if not index.data(PRODUCT_ID_ROLE): + return None + if col == self.version_col: + role = VERSION_NAME_ROLE + elif col == 1: + role = PRODUCT_TYPE_ROLE + elif col == 2: + role = FOLDER_LABEL_ROLE + elif col == 4: + role = VERSION_PUBLISH_TIME_ROLE + elif col == 5: + role = VERSION_AUTHOR_ROLE + elif col == 6: + role = VERSION_FRAME_RANGE_ROLE + elif col == 7: + role = VERSION_DURATION_ROLE + elif col == 8: + role = VERSION_HANDLES_ROLE + elif col == 9: + role = VERSION_STEP_ROLE + elif col == 10: + role = PRODUCT_IN_SCENE_ROLE + elif col == 11: + role = VERSION_AVAILABLE_ROLE + else: + return None + + index = self.index(index.row(), 0, index.parent()) + + return super(ProductsModel, self).data(index, role) + + def setData(self, index, value, role=None): + if not index.isValid(): + return False + + if role is None: + role = QtCore.Qt.EditRole + + col = index.column() + if col == self.version_col and role == QtCore.Qt.EditRole: + role = VERSION_NAME_EDIT_ROLE + + if role == VERSION_NAME_EDIT_ROLE: + if col != 0: + index = self.index(index.row(), 0, index.parent()) + product_id = index.data(PRODUCT_ID_ROLE) + product_item = self._product_items_by_id[product_id] + final_version_item = None + for v_id, version_item in product_item.version_items.items(): + if v_id == value: + final_version_item = version_item + break + + if final_version_item is None: + return False + if index.data(VERSION_ID_ROLE) == final_version_item.version_id: + return True + item = self.itemFromIndex(index) + self._set_version_data_to_product_item(item, final_version_item) + self.version_changed.emit() + return True + return super(ProductsModel, self).setData(index, value, role) + + def _get_next_color(self): + return next(self._color_iterator) + + def _color_iter(self): + while True: + for color in self.merged_items_colors: + if self._reset_merge_color: + self._reset_merge_color = False + break + yield color + + def _clear(self): + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + self._items_by_id = {} + self._group_items_by_name = {} + self._merged_items_by_id = {} + self._product_items_by_id = {} + self._reset_merge_color = True + + def _get_group_icon(self): + if self._group_icon is None: + self._group_icon = qtawesome.icon( + "fa.object-group", + color=get_default_entity_icon_color() + ) + return self._group_icon + + def _get_group_model_item(self, group_name): + model_item = self._group_items_by_name.get(group_name) + if model_item is None: + model_item = QtGui.QStandardItem(group_name) + model_item.setData( + self._get_group_icon(), QtCore.Qt.DecorationRole + ) + model_item.setData(0, GROUP_TYPE_ROLE) + model_item.setEditable(False) + model_item.setColumnCount(self.columnCount()) + self._group_items_by_name[group_name] = model_item + return model_item + + def _get_merged_model_item(self, path, count, hex_color): + model_item = self._merged_items_by_id.get(path) + if model_item is None: + model_item = QtGui.QStandardItem() + model_item.setData(1, GROUP_TYPE_ROLE) + model_item.setData(hex_color, MERGED_COLOR_ROLE) + model_item.setEditable(False) + model_item.setColumnCount(self.columnCount()) + self._merged_items_by_id[path] = model_item + label = "{} ({})".format(path, count) + model_item.setData(label, QtCore.Qt.DisplayRole) + return model_item + + def _set_version_data_to_product_item(self, model_item, version_item): + """ + + Args: + model_item (QtGui.QStandardItem): Item which should have values + from version item. + version_item (VersionItem): Item from entities model with + information about version. + """ + + model_item.setData(version_item.version_id, VERSION_ID_ROLE) + model_item.setData(version_item.version, VERSION_NAME_ROLE) + model_item.setData(version_item.version_id, VERSION_ID_ROLE) + model_item.setData(version_item.is_hero, VERSION_HERO_ROLE) + model_item.setData( + version_item.published_time, VERSION_PUBLISH_TIME_ROLE + ) + model_item.setData(version_item.author, VERSION_AUTHOR_ROLE) + model_item.setData(version_item.frame_range, VERSION_FRAME_RANGE_ROLE) + model_item.setData(version_item.duration, VERSION_DURATION_ROLE) + model_item.setData(version_item.handles, VERSION_HANDLES_ROLE) + model_item.setData(version_item.step, VERSION_STEP_ROLE) + model_item.setData( + version_item.thumbnail_id, VERSION_THUMBNAIL_ID_ROLE) + + def _get_product_model_item(self, product_item): + model_item = self._items_by_id.get(product_item.product_id) + versions = list(product_item.version_items.values()) + versions.sort() + last_version = versions[-1] + if model_item is None: + product_id = product_item.product_id + model_item = QtGui.QStandardItem(product_item.product_name) + model_item.setEditable(False) + icon = get_qt_icon(product_item.product_icon) + product_type_icon = get_qt_icon(product_item.product_type_icon) + model_item.setColumnCount(self.columnCount()) + model_item.setData(icon, QtCore.Qt.DecorationRole) + model_item.setData(product_id, PRODUCT_ID_ROLE) + model_item.setData(product_item.product_name, PRODUCT_NAME_ROLE) + model_item.setData(product_item.product_type, PRODUCT_TYPE_ROLE) + model_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE) + model_item.setData(product_item.folder_id, FOLDER_ID_ROLE) + + self._product_items_by_id[product_id] = product_item + self._items_by_id[product_id] = model_item + + model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE) + in_scene = 1 if product_item.product_in_scene else 0 + model_item.setData(in_scene, PRODUCT_IN_SCENE_ROLE) + + self._set_version_data_to_product_item(model_item, last_version) + return model_item + + def get_last_project_name(self): + return self._last_project_name + + def refresh(self, project_name, folder_ids): + self._clear() + + self._last_project_name = project_name + self._last_folder_ids = folder_ids + + product_items = self._controller.get_product_items( + project_name, + folder_ids, + sender=PRODUCTS_MODEL_SENDER_NAME + ) + product_items_by_id = { + product_item.product_id: product_item + for product_item in product_items + } + + # Prepare product groups + product_name_matches_by_group = collections.defaultdict(dict) + for product_item in product_items_by_id.values(): + group_name = None + if self._grouping_enabled: + group_name = product_item.group_name + + product_name = product_item.product_name + group = product_name_matches_by_group[group_name] + if product_name not in group: + group[product_name] = [product_item] + continue + group[product_name].append(product_item) + + group_names = set(product_name_matches_by_group.keys()) + + root_item = self.invisibleRootItem() + new_root_items = [] + merged_paths = set() + for group_name in group_names: + key_parts = [] + if group_name: + key_parts.append(group_name) + + groups = product_name_matches_by_group[group_name] + merged_product_items = {} + top_items = [] + group_product_types = set() + for product_name, product_items in groups.items(): + group_product_types |= {p.product_type for p in product_items} + if len(product_items) == 1: + top_items.append(product_items[0]) + else: + path = "/".join(key_parts + [product_name]) + merged_paths.add(path) + merged_product_items[path] = ( + product_name, + product_items, + ) + + parent_item = None + if group_name: + parent_item = self._get_group_model_item(group_name) + parent_item.setData( + "|".join(group_product_types), PRODUCT_TYPE_ROLE) + + new_items = [] + if parent_item is not None and parent_item.row() < 0: + new_root_items.append(parent_item) + + for product_item in top_items: + item = self._get_product_model_item(product_item) + new_items.append(item) + + for path_info in merged_product_items.values(): + product_name, product_items = path_info + (merged_color_hex, merged_color_qt) = self._get_next_color() + merged_color = qtawesome.icon( + "fa.circle", color=merged_color_qt) + merged_item = self._get_merged_model_item( + product_name, len(product_items), merged_color_hex) + merged_item.setData(merged_color, QtCore.Qt.DecorationRole) + new_items.append(merged_item) + + merged_product_types = set() + new_merged_items = [] + for product_item in product_items: + item = self._get_product_model_item(product_item) + new_merged_items.append(item) + merged_product_types.add(product_item.product_type) + + merged_item.setData( + "|".join(merged_product_types), PRODUCT_TYPE_ROLE) + if new_merged_items: + merged_item.appendRows(new_merged_items) + + if not new_items: + continue + + if parent_item is None: + new_root_items.extend(new_items) + else: + parent_item.appendRows(new_items) + + if new_root_items: + root_item.appendRows(new_root_items) + + self.refreshed.emit() + # --------------------------------- + # This implementation does not call '_clear' at the start + # but is more complex and probably slower + # --------------------------------- + # def _remove_items(self, items): + # if not items: + # return + # root_item = self.invisibleRootItem() + # for item in items: + # row = item.row() + # if row < 0: + # continue + # parent = item.parent() + # if parent is None: + # parent = root_item + # parent.removeRow(row) + # + # def _remove_group_items(self, group_names): + # group_items = [ + # self._group_items_by_name.pop(group_name) + # for group_name in group_names + # ] + # self._remove_items(group_items) + # + # def _remove_merged_items(self, paths): + # merged_items = [ + # self._merged_items_by_id.pop(path) + # for path in paths + # ] + # self._remove_items(merged_items) + # + # def _remove_product_items(self, product_ids): + # product_items = [] + # for product_id in product_ids: + # self._product_items_by_id.pop(product_id) + # product_items.append(self._items_by_id.pop(product_id)) + # self._remove_items(product_items) + # + # def _add_to_new_items(self, item, parent_item, new_items, root_item): + # if item.row() < 0: + # new_items.append(item) + # else: + # item_parent = item.parent() + # if item_parent is not parent_item: + # if item_parent is None: + # item_parent = root_item + # item_parent.takeRow(item.row()) + # new_items.append(item) + + # def refresh(self, project_name, folder_ids): + # product_items = self._controller.get_product_items( + # project_name, + # folder_ids, + # sender=PRODUCTS_MODEL_SENDER_NAME + # ) + # product_items_by_id = { + # product_item.product_id: product_item + # for product_item in product_items + # } + # # Remove product items that are not available + # product_ids_to_remove = ( + # set(self._items_by_id.keys()) - set(product_items_by_id.keys()) + # ) + # self._remove_product_items(product_ids_to_remove) + # + # # Prepare product groups + # product_name_matches_by_group = collections.defaultdict(dict) + # for product_item in product_items_by_id.values(): + # group_name = None + # if self._grouping_enabled: + # group_name = product_item.group_name + # + # product_name = product_item.product_name + # group = product_name_matches_by_group[group_name] + # if product_name not in group: + # group[product_name] = [product_item] + # continue + # group[product_name].append(product_item) + # + # group_names = set(product_name_matches_by_group.keys()) + # + # root_item = self.invisibleRootItem() + # new_root_items = [] + # merged_paths = set() + # for group_name in group_names: + # key_parts = [] + # if group_name: + # key_parts.append(group_name) + # + # groups = product_name_matches_by_group[group_name] + # merged_product_items = {} + # top_items = [] + # for product_name, product_items in groups.items(): + # if len(product_items) == 1: + # top_items.append(product_items[0]) + # else: + # path = "/".join(key_parts + [product_name]) + # merged_paths.add(path) + # merged_product_items[path] = product_items + # + # parent_item = None + # if group_name: + # parent_item = self._get_group_model_item(group_name) + # + # new_items = [] + # if parent_item is not None and parent_item.row() < 0: + # new_root_items.append(parent_item) + # + # for product_item in top_items: + # item = self._get_product_model_item(product_item) + # self._add_to_new_items( + # item, parent_item, new_items, root_item + # ) + # + # for path, product_items in merged_product_items.items(): + # merged_item = self._get_merged_model_item(path) + # self._add_to_new_items( + # merged_item, parent_item, new_items, root_item + # ) + # + # new_merged_items = [] + # for product_item in product_items: + # item = self._get_product_model_item(product_item) + # self._add_to_new_items( + # item, merged_item, new_merged_items, root_item + # ) + # + # if new_merged_items: + # merged_item.appendRows(new_merged_items) + # + # if not new_items: + # continue + # + # if parent_item is not None: + # parent_item.appendRows(new_items) + # continue + # + # new_root_items.extend(new_items) + # + # root_item.appendRows(new_root_items) + # + # merged_item_ids_to_remove = ( + # set(self._merged_items_by_id.keys()) - merged_paths + # ) + # group_names_to_remove = ( + # set(self._group_items_by_name.keys()) - set(group_names) + # ) + # self._remove_merged_items(merged_item_ids_to_remove) + # self._remove_group_items(group_names_to_remove) diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py new file mode 100644 index 0000000000..2d4959dc19 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -0,0 +1,400 @@ +import collections + +from qtpy import QtWidgets, QtCore + +from openpype.tools.utils import ( + RecursiveSortFilterProxyModel, + DeselectableTreeView, +) +from openpype.tools.utils.delegates import PrettyTimeDelegate + +from .products_model import ( + ProductsModel, + PRODUCTS_MODEL_SENDER_NAME, + PRODUCT_TYPE_ROLE, + GROUP_TYPE_ROLE, + MERGED_COLOR_ROLE, + FOLDER_ID_ROLE, + PRODUCT_ID_ROLE, + VERSION_ID_ROLE, + VERSION_THUMBNAIL_ID_ROLE, +) +from .products_delegates import VersionDelegate, LoadedInSceneDelegate +from .actions_utils import show_actions_menu + + +class ProductsProxyModel(RecursiveSortFilterProxyModel): + def __init__(self, parent=None): + super(ProductsProxyModel, self).__init__(parent) + + self._product_type_filters = {} + self._ascending_sort = True + + def set_product_type_filters(self, product_type_filters): + self._product_type_filters = product_type_filters + self.invalidateFilter() + + def filterAcceptsRow(self, source_row, source_parent): + source_model = self.sourceModel() + index = source_model.index(source_row, 0, source_parent) + product_types_s = source_model.data(index, PRODUCT_TYPE_ROLE) + product_types = [] + if product_types_s: + product_types = product_types_s.split("|") + + for product_type in product_types: + if not self._product_type_filters.get(product_type, True): + return False + return super(ProductsProxyModel, self).filterAcceptsRow( + source_row, source_parent) + + def lessThan(self, left, right): + l_model = left.model() + r_model = right.model() + left_group_type = l_model.data(left, GROUP_TYPE_ROLE) + right_group_type = r_model.data(right, GROUP_TYPE_ROLE) + # Groups are always on top, merged product types are below + # and items without group at the bottom + # QUESTION Do we need to do it this way? + if left_group_type != right_group_type: + if left_group_type is None: + output = False + elif right_group_type is None: + output = True + else: + output = left_group_type < right_group_type + if not self._ascending_sort: + output = not output + return output + return super(ProductsProxyModel, self).lessThan(left, right) + + def sort(self, column, order=None): + if order is None: + order = QtCore.Qt.AscendingOrder + self._ascending_sort = order == QtCore.Qt.AscendingOrder + super(ProductsProxyModel, self).sort(column, order) + + +class ProductsWidget(QtWidgets.QWidget): + refreshed = QtCore.Signal() + merged_products_selection_changed = QtCore.Signal() + selection_changed = QtCore.Signal() + version_changed = QtCore.Signal() + default_widths = ( + 200, # Product name + 90, # Product type + 130, # Folder label + 60, # Version + 125, # Time + 75, # Author + 75, # Frames + 60, # Duration + 55, # Handles + 10, # Step + 25, # Loaded in scene + 65, # Site info (maybe?) + ) + + def __init__(self, controller, parent): + super(ProductsWidget, self).__init__(parent) + + self._controller = controller + + products_view = DeselectableTreeView(self) + # TODO - define custom object name in style + products_view.setObjectName("SubsetView") + products_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + products_view.setAllColumnsShowFocus(True) + # TODO - add context menu + products_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + products_view.setSortingEnabled(True) + # Sort by product type + products_view.sortByColumn(1, QtCore.Qt.AscendingOrder) + products_view.setAlternatingRowColors(True) + + products_model = ProductsModel(controller) + products_proxy_model = ProductsProxyModel() + products_proxy_model.setSourceModel(products_model) + + products_view.setModel(products_proxy_model) + + for idx, width in enumerate(self.default_widths): + products_view.setColumnWidth(idx, width) + + version_delegate = VersionDelegate() + products_view.setItemDelegateForColumn( + products_model.version_col, version_delegate) + + time_delegate = PrettyTimeDelegate() + products_view.setItemDelegateForColumn( + products_model.published_time_col, time_delegate) + + in_scene_delegate = LoadedInSceneDelegate() + products_view.setItemDelegateForColumn( + products_model.in_scene_col, in_scene_delegate) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(products_view, 1) + + products_proxy_model.rowsInserted.connect(self._on_rows_inserted) + products_proxy_model.rowsMoved.connect(self._on_rows_moved) + products_model.refreshed.connect(self._on_refresh) + products_view.customContextMenuRequested.connect( + self._on_context_menu) + products_view.selectionModel().selectionChanged.connect( + self._on_selection_change) + products_model.version_changed.connect(self._on_version_change) + + controller.register_event_callback( + "selection.folders.changed", + self._on_folders_selection_change, + ) + controller.register_event_callback( + "products.refresh.finished", + self._on_products_refresh_finished + ) + controller.register_event_callback( + "products.group.changed", + self._on_group_changed + ) + + self._products_view = products_view + self._products_model = products_model + self._products_proxy_model = products_proxy_model + + self._version_delegate = version_delegate + self._time_delegate = time_delegate + + self._selected_project_name = None + self._selected_folder_ids = set() + + self._selected_merged_products = [] + self._selected_versions_info = [] + + # Set initial state of widget + # - Hide folders column + self._update_folders_label_visible() + # - Hide in scene column if is not supported (this won't change) + products_view.setColumnHidden( + products_model.in_scene_col, + not controller.is_loaded_products_supported() + ) + + def set_name_filter(self, name): + """Set filter of product name. + + Args: + name (str): The string filter. + """ + + self._products_proxy_model.setFilterFixedString(name) + + def set_product_type_filter(self, product_type_filters): + """ + + Args: + product_type_filters (dict[str, bool]): The filter of product + types. + """ + + self._products_proxy_model.set_product_type_filters( + product_type_filters + ) + + def set_enable_grouping(self, enable_grouping): + self._products_model.set_enable_grouping(enable_grouping) + + def get_selected_merged_products(self): + return self._selected_merged_products + + def get_selected_version_info(self): + return self._selected_versions_info + + def refresh(self): + self._refresh_model() + + def _fill_version_editor(self): + model = self._products_proxy_model + index_queue = collections.deque() + for row in range(model.rowCount()): + index_queue.append((row, None)) + + version_col = self._products_model.version_col + while index_queue: + (row, parent_index) = index_queue.popleft() + args = [row, 0] + if parent_index is not None: + args.append(parent_index) + index = model.index(*args) + rows = model.rowCount(index) + for row in range(rows): + index_queue.append((row, index)) + + product_id = model.data(index, PRODUCT_ID_ROLE) + if product_id is not None: + args[1] = version_col + v_index = model.index(*args) + self._products_view.openPersistentEditor(v_index) + + def _on_refresh(self): + self._fill_version_editor() + self.refreshed.emit() + + def _on_rows_inserted(self): + self._fill_version_editor() + + def _on_rows_moved(self): + self._fill_version_editor() + + def _refresh_model(self): + self._products_model.refresh( + self._selected_project_name, + self._selected_folder_ids + ) + + def _on_context_menu(self, point): + selection_model = self._products_view.selectionModel() + model = self._products_view.model() + project_name = self._products_model.get_last_project_name() + + version_ids = set() + indexes_queue = collections.deque() + indexes_queue.extend(selection_model.selectedIndexes()) + while indexes_queue: + index = indexes_queue.popleft() + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + version_id = model.data(index, VERSION_ID_ROLE) + if version_id is not None: + version_ids.add(version_id) + + action_items = self._controller.get_versions_action_items( + project_name, version_ids) + + # Prepare global point where to show the menu + global_point = self._products_view.mapToGlobal(point) + + result = show_actions_menu( + action_items, + global_point, + len(version_ids) == 1, + self + ) + action_item, options = result + if action_item is None or options is None: + return + + self._controller.trigger_action_item( + action_item.identifier, + options, + action_item.project_name, + version_ids=action_item.version_ids, + representation_ids=action_item.representation_ids, + ) + + def _on_selection_change(self): + selected_merged_products = [] + selection_model = self._products_view.selectionModel() + model = self._products_view.model() + indexes_queue = collections.deque() + indexes_queue.extend(selection_model.selectedIndexes()) + + # Helper for 'version_items' to avoid duplicated items + all_product_ids = set() + selected_version_ids = set() + # Version items contains information about selected version items + selected_versions_info = [] + while indexes_queue: + index = indexes_queue.popleft() + if index.column() != 0: + continue + + group_type = model.data(index, GROUP_TYPE_ROLE) + if group_type is None: + product_id = model.data(index, PRODUCT_ID_ROLE) + # Skip duplicates - when group and item are selected the item + # would be in the loop multiple times + if product_id in all_product_ids: + continue + + all_product_ids.add(product_id) + + version_id = model.data(index, VERSION_ID_ROLE) + selected_version_ids.add(version_id) + + thumbnail_id = model.data(index, VERSION_THUMBNAIL_ID_ROLE) + selected_versions_info.append({ + "folder_id": model.data(index, FOLDER_ID_ROLE), + "product_id": product_id, + "version_id": version_id, + "thumbnail_id": thumbnail_id, + }) + continue + + if group_type == 0: + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + continue + + if group_type != 1: + continue + + item_folder_ids = set() + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + + folder_id = model.data(child_index, FOLDER_ID_ROLE) + item_folder_ids.add(folder_id) + + if not item_folder_ids: + continue + + hex_color = model.data(index, MERGED_COLOR_ROLE) + item_data = { + "color": hex_color, + "folder_ids": item_folder_ids + } + selected_merged_products.append(item_data) + + prev_selected_merged_products = self._selected_merged_products + self._selected_merged_products = selected_merged_products + self._selected_versions_info = selected_versions_info + + if selected_merged_products != prev_selected_merged_products: + self.merged_products_selection_changed.emit() + self.selection_changed.emit() + self._controller.set_selected_versions(selected_version_ids) + + def _on_version_change(self): + self._on_selection_change() + + def _on_folders_selection_change(self, event): + self._selected_project_name = event["project_name"] + self._selected_folder_ids = event["folder_ids"] + self._refresh_model() + self._update_folders_label_visible() + + def _update_folders_label_visible(self): + folders_label_hidden = len(self._selected_folder_ids) <= 1 + self._products_view.setColumnHidden( + self._products_model.folders_label_col, + folders_label_hidden + ) + + def _on_products_refresh_finished(self, event): + if event["sender"] != PRODUCTS_MODEL_SENDER_NAME: + self._refresh_model() + + def _on_group_changed(self, event): + if event["project_name"] != self._selected_project_name: + return + folder_ids = event["folder_ids"] + if not set(folder_ids).intersection(set(self._selected_folder_ids)): + return + self.refresh() diff --git a/openpype/tools/ayon_loader/ui/repres_widget.py b/openpype/tools/ayon_loader/ui/repres_widget.py new file mode 100644 index 0000000000..7de582e629 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/repres_widget.py @@ -0,0 +1,338 @@ +import collections + +from qtpy import QtWidgets, QtGui, QtCore +import qtawesome + +from openpype.style import get_default_entity_icon_color +from openpype.tools.ayon_utils.widgets import get_qt_icon +from openpype.tools.utils import DeselectableTreeView + +from .actions_utils import show_actions_menu + +REPRESENTAION_NAME_ROLE = QtCore.Qt.UserRole + 1 +REPRESENTATION_ID_ROLE = QtCore.Qt.UserRole + 2 +PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 3 +FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 4 +GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 5 + + +class RepresentationsModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() + colums_info = [ + ("Name", 120), + ("Product name", 125), + ("Folder", 125), + # ("Active site", 85), + # ("Remote site", 85) + ] + column_labels = [label for label, _ in colums_info] + column_widths = [width for _, width in colums_info] + folder_column = column_labels.index("Product name") + + def __init__(self, controller): + super(RepresentationsModel, self).__init__() + + self.setColumnCount(len(self.column_labels)) + + for idx, label in enumerate(self.column_labels): + self.setHeaderData(idx, QtCore.Qt.Horizontal, label) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_change + ) + controller.register_event_callback( + "selection.versions.changed", + self._on_version_change + ) + self._selected_project_name = None + self._selected_version_ids = None + + self._group_icon = None + + self._items_by_id = {} + self._groups_items_by_name = {} + + self._controller = controller + + def refresh(self): + repre_items = self._controller.get_representation_items( + self._selected_project_name, self._selected_version_ids + ) + self._fill_items(repre_items) + self.refreshed.emit() + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + col = index.column() + if col != 0: + if role == QtCore.Qt.DecorationRole: + return None + + if role == QtCore.Qt.DisplayRole: + if col == 1: + role = PRODUCT_NAME_ROLE + elif col == 2: + role = FOLDER_LABEL_ROLE + index = self.index(index.row(), 0, index.parent()) + return super(RepresentationsModel, self).data(index, role) + + def setData(self, index, value, role=None): + if role is None: + role = QtCore.Qt.EditRole + return super(RepresentationsModel, self).setData(index, value, role) + + def _clear_items(self): + self._items_by_id = {} + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + def _get_repre_item(self, repre_item): + repre_id = repre_item.representation_id + repre_name = repre_item.representation_name + repre_icon = repre_item.representation_icon + item = self._items_by_id.get(repre_id) + is_new_item = False + if item is None: + is_new_item = True + item = QtGui.QStandardItem() + self._items_by_id[repre_id] = item + item.setColumnCount(self.columnCount()) + item.setEditable(False) + + icon = get_qt_icon(repre_icon) + item.setData(repre_name, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(repre_name, REPRESENTAION_NAME_ROLE) + item.setData(repre_id, REPRESENTATION_ID_ROLE) + item.setData(repre_item.product_name, PRODUCT_NAME_ROLE) + item.setData(repre_item.folder_label, FOLDER_LABEL_ROLE) + return is_new_item, item + + def _get_group_icon(self): + if self._group_icon is None: + self._group_icon = qtawesome.icon( + "fa.folder", + color=get_default_entity_icon_color() + ) + return self._group_icon + + def _get_group_item(self, repre_name): + item = self._groups_items_by_name.get(repre_name) + if item is not None: + return False, item + + # TODO add color + item = QtGui.QStandardItem() + item.setColumnCount(self.columnCount()) + item.setData(repre_name, QtCore.Qt.DisplayRole) + item.setData(self._get_group_icon(), QtCore.Qt.DecorationRole) + item.setData(0, GROUP_TYPE_ROLE) + item.setEditable(False) + self._groups_items_by_name[repre_name] = item + return True, item + + def _fill_items(self, repre_items): + items_to_remove = set(self._items_by_id.keys()) + repre_items_by_name = collections.defaultdict(list) + for repre_item in repre_items: + items_to_remove.discard(repre_item.representation_id) + repre_name = repre_item.representation_name + repre_items_by_name[repre_name].append(repre_item) + + root_item = self.invisibleRootItem() + for repre_id in items_to_remove: + item = self._items_by_id.pop(repre_id) + parent_item = item.parent() + if parent_item is None: + parent_item = root_item + parent_item.removeRow(item.row()) + + group_names = set() + new_root_items = [] + for repre_name, repre_name_items in repre_items_by_name.items(): + group_item = None + parent_is_group = False + if len(repre_name_items) > 1: + group_names.add(repre_name) + is_new_group, group_item = self._get_group_item(repre_name) + if is_new_group: + new_root_items.append(group_item) + parent_is_group = True + + new_group_items = [] + for repre_item in repre_name_items: + is_new_item, item = self._get_repre_item(repre_item) + item_parent = item.parent() + if item_parent is None: + item_parent = root_item + + if not is_new_item: + if parent_is_group: + if item_parent is group_item: + continue + elif item_parent is root_item: + continue + item_parent.takeRow(item.row()) + is_new_item = True + + if is_new_item: + new_group_items.append(item) + + if not new_group_items: + continue + + if group_item is not None: + group_item.appendRows(new_group_items) + else: + new_root_items.extend(new_group_items) + + if new_root_items: + root_item.appendRows(new_root_items) + + for group_name in set(self._groups_items_by_name) - group_names: + item = self._groups_items_by_name.pop(group_name) + parent_item = item.parent() + if parent_item is None: + parent_item = root_item + parent_item.removeRow(item.row()) + + def _on_project_change(self, event): + self._selected_project_name = event["project_name"] + + def _on_version_change(self, event): + self._selected_version_ids = event["version_ids"] + self.refresh() + + +class RepresentationsWidget(QtWidgets.QWidget): + def __init__(self, controller, parent): + super(RepresentationsWidget, self).__init__(parent) + + repre_view = DeselectableTreeView(self) + repre_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + repre_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + repre_view.setSortingEnabled(True) + repre_view.setAlternatingRowColors(True) + + repre_model = RepresentationsModel(controller) + repre_proxy_model = QtCore.QSortFilterProxyModel() + repre_proxy_model.setSourceModel(repre_model) + repre_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + repre_view.setModel(repre_proxy_model) + + for idx, width in enumerate(repre_model.column_widths): + repre_view.setColumnWidth(idx, width) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(repre_view, 1) + + repre_view.customContextMenuRequested.connect( + self._on_context_menu) + repre_view.selectionModel().selectionChanged.connect( + self._on_selection_change) + repre_model.refreshed.connect(self._on_model_refresh) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_change + ) + controller.register_event_callback( + "selection.folders.changed", + self._on_folder_change + ) + + self._controller = controller + self._selected_project_name = None + self._selected_multiple_folders = None + + self._repre_view = repre_view + self._repre_model = repre_model + self._repre_proxy_model = repre_proxy_model + + self._set_multiple_folders_selected(False) + + def refresh(self): + self._repre_model.refresh() + + def _on_folder_change(self, event): + self._set_multiple_folders_selected(len(event["folder_ids"]) > 1) + + def _on_project_change(self, event): + self._selected_project_name = event["project_name"] + + def _set_multiple_folders_selected(self, selected_multiple_folders): + if selected_multiple_folders == self._selected_multiple_folders: + return + self._selected_multiple_folders = selected_multiple_folders + self._repre_view.setColumnHidden( + self._repre_model.folder_column, + not self._selected_multiple_folders + ) + + def _on_model_refresh(self): + self._repre_proxy_model.sort(0) + + def _get_selected_repre_indexes(self): + selection_model = self._repre_view.selectionModel() + model = self._repre_view.model() + indexes_queue = collections.deque() + indexes_queue.extend(selection_model.selectedIndexes()) + + selected_indexes = [] + while indexes_queue: + index = indexes_queue.popleft() + if index.column() != 0: + continue + + group_type = model.data(index, GROUP_TYPE_ROLE) + if group_type is None: + selected_indexes.append(index) + + elif group_type == 0: + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + + return selected_indexes + + def _get_selected_repre_ids(self): + repre_ids = { + index.data(REPRESENTATION_ID_ROLE) + for index in self._get_selected_repre_indexes() + } + repre_ids.discard(None) + return repre_ids + + def _on_selection_change(self): + selected_repre_ids = self._get_selected_repre_ids() + self._controller.set_selected_representations(selected_repre_ids) + + def _on_context_menu(self, point): + repre_ids = self._get_selected_repre_ids() + action_items = self._controller.get_representations_action_items( + self._selected_project_name, repre_ids + ) + global_point = self._repre_view.mapToGlobal(point) + result = show_actions_menu( + action_items, + global_point, + len(repre_ids) == 1, + self + ) + action_item, options = result + if action_item is None or options is None: + return + + self._controller.trigger_action_item( + action_item.identifier, + options, + action_item.project_name, + version_ids=action_item.version_ids, + representation_ids=action_item.representation_ids, + ) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py new file mode 100644 index 0000000000..a6d40d52e7 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/window.py @@ -0,0 +1,511 @@ +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.resources import get_openpype_icon_filepath +from openpype.style import load_stylesheet +from openpype.tools.utils import ( + PlaceholderLineEdit, + ErrorMessageBox, + ThumbnailPainterWidget, + RefreshButton, + GoToCurrentButton, +) +from openpype.tools.utils.lib import center_window +from openpype.tools.ayon_utils.widgets import ProjectsCombobox +from openpype.tools.ayon_loader.control import LoaderController + +from .folders_widget import LoaderFoldersWidget +from .products_widget import ProductsWidget +from .product_types_widget import ProductTypesView +from .product_group_dialog import ProductGroupDialog +from .info_widget import InfoWidget +from .repres_widget import RepresentationsWidget + + +class LoadErrorMessageBox(ErrorMessageBox): + def __init__(self, messages, parent=None): + self._messages = messages + super(LoadErrorMessageBox, self).__init__("Loading failed", parent) + + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( + "Failed to load items" + ) + return label_widget + + def _get_report_data(self): + report_data = [] + for exc_msg, tb_text, repre, product, version in self._messages: + report_message = ( + "During load error happened on Product: \"{product}\"" + " Representation: \"{repre}\" Version: {version}" + "\n\nError message: {message}" + ).format( + product=product, + repre=repre, + version=version, + message=exc_msg + ) + if tb_text: + report_message += "\n\n{}".format(tb_text) + report_data.append(report_message) + return report_data + + def _create_content(self, content_layout): + item_name_template = ( + "Product: {}
" + "Version: {}
" + "Representation: {}
" + ) + exc_msg_template = "{}" + + for exc_msg, tb_text, repre, product, version in self._messages: + line = self._create_line() + content_layout.addWidget(line) + + item_name = item_name_template.format(product, version, repre) + item_name_widget = QtWidgets.QLabel( + item_name.replace("\n", "
"), self + ) + item_name_widget.setWordWrap(True) + content_layout.addWidget(item_name_widget) + + exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) + message_label_widget = QtWidgets.QLabel(exc_msg, self) + message_label_widget.setWordWrap(True) + content_layout.addWidget(message_label_widget) + + if tb_text: + line = self._create_line() + tb_widget = self._create_traceback_widget(tb_text, self) + content_layout.addWidget(line) + content_layout.addWidget(tb_widget) + + +class RefreshHandler: + def __init__(self): + self._project_refreshed = False + self._folders_refreshed = False + self._products_refreshed = False + + @property + def project_refreshed(self): + return self._products_refreshed + + @property + def folders_refreshed(self): + return self._folders_refreshed + + @property + def products_refreshed(self): + return self._products_refreshed + + def reset(self): + self._project_refreshed = False + self._folders_refreshed = False + self._products_refreshed = False + + def set_project_refreshed(self): + self._project_refreshed = True + + def set_folders_refreshed(self): + self._folders_refreshed = True + + def set_products_refreshed(self): + self._products_refreshed = True + + +class LoaderWindow(QtWidgets.QWidget): + def __init__(self, controller=None, parent=None): + super(LoaderWindow, self).__init__(parent) + + icon = QtGui.QIcon(get_openpype_icon_filepath()) + self.setWindowIcon(icon) + self.setWindowTitle("AYON Loader") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) + self.setWindowFlags(self.windowFlags() | QtCore.Qt.Window) + + if controller is None: + controller = LoaderController() + + main_splitter = QtWidgets.QSplitter(self) + + context_splitter = QtWidgets.QSplitter(main_splitter) + context_splitter.setOrientation(QtCore.Qt.Vertical) + + # Context selection widget + context_widget = QtWidgets.QWidget(context_splitter) + + context_top_widget = QtWidgets.QWidget(context_widget) + projects_combobox = ProjectsCombobox( + controller, + context_top_widget, + handle_expected_selection=True + ) + projects_combobox.set_select_item_visible(True) + projects_combobox.set_libraries_separator_visible(True) + projects_combobox.set_standard_filter_enabled( + controller.is_standard_projects_filter_enabled() + ) + + go_to_current_btn = GoToCurrentButton(context_top_widget) + refresh_btn = RefreshButton(context_top_widget) + + context_top_layout = QtWidgets.QHBoxLayout(context_top_widget) + context_top_layout.setContentsMargins(0, 0, 0, 0,) + context_top_layout.addWidget(projects_combobox, 1) + context_top_layout.addWidget(go_to_current_btn, 0) + context_top_layout.addWidget(refresh_btn, 0) + + folders_filter_input = PlaceholderLineEdit(context_widget) + folders_filter_input.setPlaceholderText("Folder name filter...") + + folders_widget = LoaderFoldersWidget(controller, context_widget) + + product_types_widget = ProductTypesView(controller, context_splitter) + + context_layout = QtWidgets.QVBoxLayout(context_widget) + context_layout.setContentsMargins(0, 0, 0, 0) + context_layout.addWidget(context_top_widget, 0) + context_layout.addWidget(folders_filter_input, 0) + context_layout.addWidget(folders_widget, 1) + + context_splitter.addWidget(context_widget) + context_splitter.addWidget(product_types_widget) + context_splitter.setStretchFactor(0, 65) + context_splitter.setStretchFactor(1, 35) + + # Product + version selection item + products_wrap_widget = QtWidgets.QWidget(main_splitter) + + products_inputs_widget = QtWidgets.QWidget(products_wrap_widget) + + products_filter_input = PlaceholderLineEdit(products_inputs_widget) + products_filter_input.setPlaceholderText("Product name filter...") + product_group_checkbox = QtWidgets.QCheckBox( + "Enable grouping", products_inputs_widget) + product_group_checkbox.setChecked(True) + + products_widget = ProductsWidget(controller, products_wrap_widget) + + products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget) + products_inputs_layout.setContentsMargins(0, 0, 0, 0) + products_inputs_layout.addWidget(products_filter_input, 1) + products_inputs_layout.addWidget(product_group_checkbox, 0) + + products_wrap_layout = QtWidgets.QVBoxLayout(products_wrap_widget) + products_wrap_layout.setContentsMargins(0, 0, 0, 0) + products_wrap_layout.addWidget(products_inputs_widget, 0) + products_wrap_layout.addWidget(products_widget, 1) + + right_panel_splitter = QtWidgets.QSplitter(main_splitter) + right_panel_splitter.setOrientation(QtCore.Qt.Vertical) + + thumbnails_widget = ThumbnailPainterWidget(right_panel_splitter) + thumbnails_widget.set_use_checkboard(False) + + info_widget = InfoWidget(controller, right_panel_splitter) + + repre_widget = RepresentationsWidget(controller, right_panel_splitter) + + right_panel_splitter.addWidget(thumbnails_widget) + right_panel_splitter.addWidget(info_widget) + right_panel_splitter.addWidget(repre_widget) + + right_panel_splitter.setStretchFactor(0, 1) + right_panel_splitter.setStretchFactor(1, 1) + right_panel_splitter.setStretchFactor(2, 2) + + main_splitter.addWidget(context_splitter) + main_splitter.addWidget(products_wrap_widget) + main_splitter.addWidget(right_panel_splitter) + + main_splitter.setStretchFactor(0, 4) + main_splitter.setStretchFactor(1, 6) + main_splitter.setStretchFactor(2, 1) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.addWidget(main_splitter) + + show_timer = QtCore.QTimer() + show_timer.setInterval(1) + + show_timer.timeout.connect(self._on_show_timer) + + projects_combobox.refreshed.connect(self._on_projects_refresh) + folders_widget.refreshed.connect(self._on_folders_refresh) + products_widget.refreshed.connect(self._on_products_refresh) + folders_filter_input.textChanged.connect( + self._on_folder_filter_change + ) + product_types_widget.filter_changed.connect( + self._on_product_type_filter_change + ) + products_filter_input.textChanged.connect( + self._on_product_filter_change + ) + product_group_checkbox.stateChanged.connect( + self._on_product_group_change + ) + products_widget.merged_products_selection_changed.connect( + self._on_merged_products_selection_change + ) + products_widget.selection_changed.connect( + self._on_products_selection_change + ) + go_to_current_btn.clicked.connect( + self._on_go_to_current_context_click + ) + refresh_btn.clicked.connect( + self._on_refresh_click + ) + controller.register_event_callback( + "load.finished", + self._on_load_finished, + ) + controller.register_event_callback( + "selection.project.changed", + self._on_project_selection_changed, + ) + controller.register_event_callback( + "selection.folders.changed", + self._on_folders_selection_changed, + ) + controller.register_event_callback( + "selection.versions.changed", + self._on_versions_selection_changed, + ) + controller.register_event_callback( + "controller.reset.started", + self._on_controller_reset_start, + ) + controller.register_event_callback( + "controller.reset.finished", + self._on_controller_reset_finish, + ) + + self._group_dialog = ProductGroupDialog(controller, self) + + self._main_splitter = main_splitter + + self._go_to_current_btn = go_to_current_btn + self._refresh_btn = refresh_btn + self._projects_combobox = projects_combobox + + self._folders_filter_input = folders_filter_input + self._folders_widget = folders_widget + + self._product_types_widget = product_types_widget + + self._products_filter_input = products_filter_input + self._product_group_checkbox = product_group_checkbox + self._products_widget = products_widget + + self._right_panel_splitter = right_panel_splitter + self._thumbnails_widget = thumbnails_widget + self._info_widget = info_widget + self._repre_widget = repre_widget + + self._controller = controller + self._refresh_handler = RefreshHandler() + self._first_show = True + self._reset_on_show = True + self._show_counter = 0 + self._show_timer = show_timer + self._selected_project_name = None + self._selected_folder_ids = set() + self._selected_version_ids = set() + + self._products_widget.set_enable_grouping( + self._product_group_checkbox.isChecked() + ) + + def refresh(self): + self._controller.reset() + + def showEvent(self, event): + super(LoaderWindow, self).showEvent(event) + + if self._first_show: + self._on_first_show() + + self._show_timer.start() + + def keyPressEvent(self, event): + modifiers = event.modifiers() + ctrl_pressed = QtCore.Qt.ControlModifier & modifiers + + # Grouping products on pressing Ctrl + G + if ( + ctrl_pressed + and event.key() == QtCore.Qt.Key_G + and not event.isAutoRepeat() + ): + self._show_group_dialog() + event.setAccepted(True) + return + + super(LoaderWindow, self).keyPressEvent(event) + + def _on_first_show(self): + self._first_show = False + # width, height = 1800, 900 + width, height = 1500, 750 + + self.resize(width, height) + + mid_width = int(width / 1.8) + sides_width = int((width - mid_width) * 0.5) + self._main_splitter.setSizes( + [sides_width, mid_width, sides_width] + ) + + thumbnail_height = int(height / 3.6) + info_height = int((height - thumbnail_height) * 0.5) + self._right_panel_splitter.setSizes( + [thumbnail_height, info_height, info_height] + ) + self.setStyleSheet(load_stylesheet()) + center_window(self) + + def _on_show_timer(self): + if self._show_counter < 2: + self._show_counter += 1 + return + + self._show_counter = 0 + self._show_timer.stop() + + if self._reset_on_show: + self._reset_on_show = False + self._controller.reset() + + def _show_group_dialog(self): + project_name = self._projects_combobox.get_selected_project_name() + if not project_name: + return + + product_ids = { + i["product_id"] + for i in self._products_widget.get_selected_version_info() + } + if not product_ids: + return + + self._group_dialog.set_product_ids(project_name, product_ids) + self._group_dialog.show() + + def _on_folder_filter_change(self, text): + self._folders_widget.set_name_filter(text) + + def _on_product_group_change(self): + self._products_widget.set_enable_grouping( + self._product_group_checkbox.isChecked() + ) + + def _on_product_filter_change(self, text): + self._products_widget.set_name_filter(text) + + def _on_product_type_filter_change(self): + self._products_widget.set_product_type_filter( + self._product_types_widget.get_filter_info() + ) + + def _on_merged_products_selection_change(self): + items = self._products_widget.get_selected_merged_products() + self._folders_widget.set_merged_products_selection(items) + + def _on_products_selection_change(self): + items = self._products_widget.get_selected_version_info() + self._info_widget.set_selected_version_info( + self._projects_combobox.get_selected_project_name(), + items + ) + + def _on_go_to_current_context_click(self): + context = self._controller.get_current_context() + self._controller.set_expected_selection( + context["project_name"], + context["folder_id"], + ) + + def _on_refresh_click(self): + self._controller.reset() + + def _on_controller_reset_start(self): + self._refresh_handler.reset() + + def _on_controller_reset_finish(self): + context = self._controller.get_current_context() + project_name = context["project_name"] + self._go_to_current_btn.setVisible(bool(project_name)) + self._projects_combobox.set_current_context_project(project_name) + if not self._refresh_handler.project_refreshed: + self._projects_combobox.refresh() + + def _on_load_finished(self, event): + error_info = event["error_info"] + if not error_info: + return + + box = LoadErrorMessageBox(error_info, self) + box.show() + + def _on_project_selection_changed(self, event): + self._selected_project_name = event["project_name"] + + def _on_folders_selection_changed(self, event): + self._selected_folder_ids = set(event["folder_ids"]) + self._update_thumbnails() + + def _on_versions_selection_changed(self, event): + self._selected_version_ids = set(event["version_ids"]) + self._update_thumbnails() + + def _update_thumbnails(self): + project_name = self._selected_project_name + thumbnail_ids = set() + if self._selected_version_ids: + thumbnail_id_by_entity_id = ( + self._controller.get_version_thumbnail_ids( + project_name, + self._selected_version_ids + ) + ) + thumbnail_ids = set(thumbnail_id_by_entity_id.values()) + elif self._selected_folder_ids: + thumbnail_id_by_entity_id = ( + self._controller.get_folder_thumbnail_ids( + project_name, + self._selected_folder_ids + ) + ) + thumbnail_ids = set(thumbnail_id_by_entity_id.values()) + + thumbnail_ids.discard(None) + + if not thumbnail_ids: + self._thumbnails_widget.set_current_thumbnails(None) + return + + thumbnail_paths = set() + for thumbnail_id in thumbnail_ids: + thumbnail_path = self._controller.get_thumbnail_path( + project_name, thumbnail_id) + thumbnail_paths.add(thumbnail_path) + thumbnail_paths.discard(None) + self._thumbnails_widget.set_current_thumbnail_paths(thumbnail_paths) + + def _on_projects_refresh(self): + self._refresh_handler.set_project_refreshed() + if not self._refresh_handler.folders_refreshed: + self._folders_widget.refresh() + + def _on_folders_refresh(self): + self._refresh_handler.set_folders_refreshed() + if not self._refresh_handler.products_refreshed: + self._products_widget.refresh() + + def _on_products_refresh(self): + self._refresh_handler.set_products_refreshed() diff --git a/openpype/tools/ayon_sceneinventory/__init__.py b/openpype/tools/ayon_sceneinventory/__init__.py new file mode 100644 index 0000000000..5412e2fea2 --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/__init__.py @@ -0,0 +1,6 @@ +from .control import SceneInventoryController + + +__all__ = ( + "SceneInventoryController", +) diff --git a/openpype/tools/ayon_sceneinventory/control.py b/openpype/tools/ayon_sceneinventory/control.py new file mode 100644 index 0000000000..e98b0e307b --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/control.py @@ -0,0 +1,134 @@ +import ayon_api + +from openpype.lib.events import QueuedEventSystem +from openpype.host import ILoadHost +from openpype.pipeline import ( + registered_host, + get_current_context, +) +from openpype.tools.ayon_utils.models import HierarchyModel + +from .models import SiteSyncModel + + +class SceneInventoryController: + """This is a temporary controller for AYON. + + Goal of this temporary controller is to provide a way to get current + context instead of using 'AvalonMongoDB' object (or 'legacy_io'). + + Also provides (hopefully) cleaner api for site sync. + """ + + def __init__(self, host=None): + if host is None: + host = registered_host() + self._host = host + self._current_context = None + self._current_project = None + self._current_folder_id = None + self._current_folder_set = False + + self._site_sync_model = SiteSyncModel(self) + # Switch dialog requirements + self._hierarchy_model = HierarchyModel(self) + self._event_system = self._create_event_system() + + def emit_event(self, topic, data=None, source=None): + if data is None: + data = {} + self._event_system.emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self._event_system.add_callback(topic, callback) + + def reset(self): + self._current_context = None + self._current_project = None + self._current_folder_id = None + self._current_folder_set = False + + self._site_sync_model.reset() + self._hierarchy_model.reset() + + def get_current_context(self): + if self._current_context is None: + if hasattr(self._host, "get_current_context"): + self._current_context = self._host.get_current_context() + else: + self._current_context = get_current_context() + return self._current_context + + def get_current_project_name(self): + if self._current_project is None: + self._current_project = self.get_current_context()["project_name"] + return self._current_project + + def get_current_folder_id(self): + if self._current_folder_set: + return self._current_folder_id + + context = self.get_current_context() + project_name = context["project_name"] + folder_path = context.get("folder_path") + folder_name = context.get("asset_name") + folder_id = None + if folder_path: + folder = ayon_api.get_folder_by_path(project_name, folder_path) + if folder: + folder_id = folder["id"] + elif folder_name: + for folder in ayon_api.get_folders( + project_name, folder_names=[folder_name] + ): + folder_id = folder["id"] + break + + self._current_folder_id = folder_id + self._current_folder_set = True + return self._current_folder_id + + def get_containers(self): + host = self._host + if isinstance(host, ILoadHost): + return host.get_containers() + elif hasattr(host, "ls"): + return host.ls() + return [] + + # Site Sync methods + def is_sync_server_enabled(self): + return self._site_sync_model.is_sync_server_enabled() + + def get_sites_information(self): + return self._site_sync_model.get_sites_information() + + def get_site_provider_icons(self): + return self._site_sync_model.get_site_provider_icons() + + def get_representations_site_progress(self, representation_ids): + return self._site_sync_model.get_representations_site_progress( + representation_ids + ) + + def resync_representations(self, representation_ids, site_type): + return self._site_sync_model.resync_representations( + representation_ids, site_type + ) + + # Switch dialog methods + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_folder_label(self, folder_id): + if not folder_id: + return None + project_name = self.get_current_project_name() + folder_item = self._hierarchy_model.get_folder_item( + project_name, folder_id) + if folder_item is None: + return None + return folder_item.label + + def _create_event_system(self): + return QueuedEventSystem() diff --git a/openpype/tools/ayon_sceneinventory/model.py b/openpype/tools/ayon_sceneinventory/model.py new file mode 100644 index 0000000000..16924b0a7e --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/model.py @@ -0,0 +1,622 @@ +import collections +import re +import logging +import uuid +import copy + +from collections import defaultdict + +from qtpy import QtCore, QtGui +import qtawesome + +from openpype.client import ( + get_assets, + get_subsets, + get_versions, + get_last_version_by_subset_id, + get_representations, +) +from openpype.pipeline import ( + get_current_project_name, + schema, + HeroVersionType, +) +from openpype.style import get_default_entity_icon_color +from openpype.tools.utils.models import TreeModel, Item + + +def walk_hierarchy(node): + """Recursively yield group node.""" + for child in node.children(): + if child.get("isGroupNode"): + yield child + + for _child in walk_hierarchy(child): + yield _child + + +class InventoryModel(TreeModel): + """The model for the inventory""" + + Columns = [ + "Name", + "version", + "count", + "family", + "group", + "loader", + "objectName", + "active_site", + "remote_site", + ] + active_site_col = Columns.index("active_site") + remote_site_col = Columns.index("remote_site") + + OUTDATED_COLOR = QtGui.QColor(235, 30, 30) + CHILD_OUTDATED_COLOR = QtGui.QColor(200, 160, 30) + GRAYOUT_COLOR = QtGui.QColor(160, 160, 160) + + UniqueRole = QtCore.Qt.UserRole + 2 # unique label role + + def __init__(self, controller, parent=None): + super(InventoryModel, self).__init__(parent) + self.log = logging.getLogger(self.__class__.__name__) + + self._controller = controller + + self._hierarchy_view = False + + self._default_icon_color = get_default_entity_icon_color() + + site_icons = self._controller.get_site_provider_icons() + + self._site_icons = { + provider: QtGui.QIcon(icon_path) + for provider, icon_path in site_icons.items() + } + + def outdated(self, item): + value = item.get("version") + if isinstance(value, HeroVersionType): + return False + + if item.get("version") == item.get("highest_version"): + return False + return True + + def data(self, index, role): + if not index.isValid(): + return + + item = index.internalPointer() + + if role == QtCore.Qt.FontRole: + # Make top-level entries bold + if item.get("isGroupNode") or item.get("isNotSet"): # group-item + font = QtGui.QFont() + font.setBold(True) + return font + + if role == QtCore.Qt.ForegroundRole: + # Set the text color to the OUTDATED_COLOR when the + # collected version is not the same as the highest version + key = self.Columns[index.column()] + if key == "version": # version + if item.get("isGroupNode"): # group-item + if self.outdated(item): + return self.OUTDATED_COLOR + + if self._hierarchy_view: + # If current group is not outdated, check if any + # outdated children. + for _node in walk_hierarchy(item): + if self.outdated(_node): + return self.CHILD_OUTDATED_COLOR + else: + + if self._hierarchy_view: + # Although this is not a group item, we still need + # to distinguish which one contain outdated child. + for _node in walk_hierarchy(item): + if self.outdated(_node): + return self.CHILD_OUTDATED_COLOR.darker(150) + + return self.GRAYOUT_COLOR + + if key == "Name" and not item.get("isGroupNode"): + return self.GRAYOUT_COLOR + + # Add icons + if role == QtCore.Qt.DecorationRole: + if index.column() == 0: + # Override color + color = item.get("color", self._default_icon_color) + if item.get("isGroupNode"): # group-item + return qtawesome.icon("fa.folder", color=color) + if item.get("isNotSet"): + return qtawesome.icon("fa.exclamation-circle", color=color) + + return qtawesome.icon("fa.file-o", color=color) + + if index.column() == 3: + # Family icon + return item.get("familyIcon", None) + + column_name = self.Columns[index.column()] + + if column_name == "group" and item.get("group"): + return qtawesome.icon("fa.object-group", + color=get_default_entity_icon_color()) + + if item.get("isGroupNode"): + if column_name == "active_site": + provider = item.get("active_site_provider") + return self._site_icons.get(provider) + + if column_name == "remote_site": + provider = item.get("remote_site_provider") + return self._site_icons.get(provider) + + if role == QtCore.Qt.DisplayRole and item.get("isGroupNode"): + column_name = self.Columns[index.column()] + progress = None + if column_name == "active_site": + progress = item.get("active_site_progress", 0) + elif column_name == "remote_site": + progress = item.get("remote_site_progress", 0) + if progress is not None: + return "{}%".format(max(progress, 0) * 100) + + if role == self.UniqueRole: + return item["representation"] + item.get("objectName", "") + + return super(InventoryModel, self).data(index, role) + + def set_hierarchy_view(self, state): + """Set whether to display subsets in hierarchy view.""" + state = bool(state) + + if state != self._hierarchy_view: + self._hierarchy_view = state + + def refresh(self, selected=None, containers=None): + """Refresh the model""" + + # for debugging or testing, injecting items from outside + if containers is None: + containers = self._controller.get_containers() + + self.clear() + if not selected or not self._hierarchy_view: + self._add_containers(containers) + return + + # Filter by cherry-picked items + self._add_containers(( + container + for container in containers + if container["objectName"] in selected + )) + + def _add_containers(self, containers, parent=None): + """Add the items to the model. + + The items should be formatted similar to `api.ls()` returns, an item + is then represented as: + {"filename_v001.ma": [full/filename/of/loaded/filename_v001.ma, + full/filename/of/loaded/filename_v001.ma], + "nodetype" : "reference", + "node": "referenceNode1"} + + Note: When performing an additional call to `add_items` it will *not* + group the new items with previously existing item groups of the + same type. + + Args: + containers (generator): Container items. + parent (Item, optional): Set this item as parent for the added + items when provided. Defaults to the root of the model. + + Returns: + node.Item: root node which has children added based on the data + """ + + project_name = get_current_project_name() + + self.beginResetModel() + + # Group by representation + grouped = defaultdict(lambda: {"containers": list()}) + for container in containers: + repre_id = container["representation"] + grouped[repre_id]["containers"].append(container) + + ( + repres_by_id, + versions_by_id, + products_by_id, + folders_by_id, + ) = self._query_entities(project_name, set(grouped.keys())) + # Add to model + not_found = defaultdict(list) + not_found_ids = [] + for repre_id, group_dict in sorted(grouped.items()): + group_containers = group_dict["containers"] + representation = repres_by_id.get(repre_id) + if not representation: + not_found["representation"].extend(group_containers) + not_found_ids.append(repre_id) + continue + + version = versions_by_id.get(representation["parent"]) + if not version: + not_found["version"].extend(group_containers) + not_found_ids.append(repre_id) + continue + + product = products_by_id.get(version["parent"]) + if not product: + not_found["product"].extend(group_containers) + not_found_ids.append(repre_id) + continue + + folder = folders_by_id.get(product["parent"]) + if not folder: + not_found["folder"].extend(group_containers) + not_found_ids.append(repre_id) + continue + + group_dict.update({ + "representation": representation, + "version": version, + "subset": product, + "asset": folder + }) + + for _repre_id in not_found_ids: + grouped.pop(_repre_id) + + for where, group_containers in not_found.items(): + # create the group header + group_node = Item() + name = "< NOT FOUND - {} >".format(where) + group_node["Name"] = name + group_node["representation"] = name + group_node["count"] = len(group_containers) + group_node["isGroupNode"] = False + group_node["isNotSet"] = True + + self.add_child(group_node, parent=parent) + + for container in group_containers: + item_node = Item() + item_node.update(container) + item_node["Name"] = container.get("objectName", "NO NAME") + item_node["isNotFound"] = True + self.add_child(item_node, parent=group_node) + + # TODO Use product icons + family_icon = qtawesome.icon( + "fa.folder", color="#0091B2" + ) + # Prepare site sync specific data + progress_by_id = self._controller.get_representations_site_progress( + set(grouped.keys()) + ) + sites_info = self._controller.get_sites_information() + + for repre_id, group_dict in sorted(grouped.items()): + group_containers = group_dict["containers"] + representation = group_dict["representation"] + version = group_dict["version"] + subset = group_dict["subset"] + asset = group_dict["asset"] + + # Get the primary family + maj_version, _ = schema.get_schema_version(subset["schema"]) + if maj_version < 3: + src_doc = version + else: + src_doc = subset + + prim_family = src_doc["data"].get("family") + if not prim_family: + families = src_doc["data"].get("families") + if families: + prim_family = families[0] + + # Store the highest available version so the model can know + # whether current version is currently up-to-date. + highest_version = get_last_version_by_subset_id( + project_name, version["parent"] + ) + + # create the group header + group_node = Item() + group_node["Name"] = "{}_{}: ({})".format( + asset["name"], subset["name"], representation["name"] + ) + group_node["representation"] = repre_id + group_node["version"] = version["name"] + group_node["highest_version"] = highest_version["name"] + group_node["family"] = prim_family or "" + group_node["familyIcon"] = family_icon + group_node["count"] = len(group_containers) + group_node["isGroupNode"] = True + group_node["group"] = subset["data"].get("subsetGroup") + + # Site sync specific data + progress = progress_by_id[repre_id] + group_node.update(sites_info) + group_node["active_site_progress"] = progress["active_site"] + group_node["remote_site_progress"] = progress["remote_site"] + + self.add_child(group_node, parent=parent) + + for container in group_containers: + item_node = Item() + item_node.update(container) + + # store the current version on the item + item_node["version"] = version["name"] + + # Remapping namespace to item name. + # Noted that the name key is capital "N", by doing this, we + # can view namespace in GUI without changing container data. + item_node["Name"] = container["namespace"] + + self.add_child(item_node, parent=group_node) + + self.endResetModel() + + return self._root_item + + def _query_entities(self, project_name, repre_ids): + """Query entities for representations from containers. + + Returns: + tuple[dict, dict, dict, dict]: Representation, version, product + and folder documents by id. + """ + + repres_by_id = {} + versions_by_id = {} + products_by_id = {} + folders_by_id = {} + output = ( + repres_by_id, + versions_by_id, + products_by_id, + folders_by_id, + ) + + filtered_repre_ids = set() + for repre_id in repre_ids: + # Filter out invalid representation ids + # NOTE: This is added because scenes from OpenPype did contain + # ObjectId from mongo. + try: + uuid.UUID(repre_id) + filtered_repre_ids.add(repre_id) + except ValueError: + continue + if not filtered_repre_ids: + return output + + repre_docs = get_representations(project_name, repre_ids) + repres_by_id.update({ + repre_doc["_id"]: repre_doc + for repre_doc in repre_docs + }) + version_ids = { + repre_doc["parent"] for repre_doc in repres_by_id.values() + } + if not version_ids: + return output + + version_docs = get_versions(project_name, version_ids, hero=True) + versions_by_id.update({ + version_doc["_id"]: version_doc + for version_doc in version_docs + }) + hero_versions_by_subversion_id = collections.defaultdict(list) + for version_doc in versions_by_id.values(): + if version_doc["type"] != "hero_version": + continue + subversion = version_doc["version_id"] + hero_versions_by_subversion_id[subversion].append(version_doc) + + if hero_versions_by_subversion_id: + subversion_ids = set( + hero_versions_by_subversion_id.keys() + ) + subversion_docs = get_versions(project_name, subversion_ids) + for subversion_doc in subversion_docs: + subversion_id = subversion_doc["_id"] + subversion_ids.discard(subversion_id) + h_version_docs = hero_versions_by_subversion_id[subversion_id] + for version_doc in h_version_docs: + version_doc["name"] = HeroVersionType( + subversion_doc["name"] + ) + version_doc["data"] = copy.deepcopy( + subversion_doc["data"] + ) + + for subversion_id in subversion_ids: + h_version_docs = hero_versions_by_subversion_id[subversion_id] + for version_doc in h_version_docs: + versions_by_id.pop(version_doc["_id"]) + + product_ids = { + version_doc["parent"] + for version_doc in versions_by_id.values() + } + if not product_ids: + return output + product_docs = get_subsets(project_name, product_ids) + products_by_id.update({ + product_doc["_id"]: product_doc + for product_doc in product_docs + }) + folder_ids = { + product_doc["parent"] + for product_doc in products_by_id.values() + } + if not folder_ids: + return output + + folder_docs = get_assets(project_name, folder_ids) + folders_by_id.update({ + folder_doc["_id"]: folder_doc + for folder_doc in folder_docs + }) + return output + + +class FilterProxyModel(QtCore.QSortFilterProxyModel): + """Filter model to where key column's value is in the filtered tags""" + + def __init__(self, *args, **kwargs): + super(FilterProxyModel, self).__init__(*args, **kwargs) + self._filter_outdated = False + self._hierarchy_view = False + + def filterAcceptsRow(self, row, parent): + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + + # Always allow bottom entries (individual containers), since their + # parent group hidden if it wouldn't have been validated. + rows = model.rowCount(source_index) + if not rows: + return True + + # Filter by regex + if hasattr(self, "filterRegExp"): + regex = self.filterRegExp() + else: + regex = self.filterRegularExpression() + pattern = regex.pattern() + if pattern: + pattern = re.escape(pattern) + + if not self._matches(row, parent, pattern): + return False + + if self._filter_outdated: + # When filtering to outdated we filter the up to date entries + # thus we "allow" them when they are outdated + if not self._is_outdated(row, parent): + return False + + return True + + def set_filter_outdated(self, state): + """Set whether to show the outdated entries only.""" + state = bool(state) + + if state != self._filter_outdated: + self._filter_outdated = bool(state) + self.invalidateFilter() + + def set_hierarchy_view(self, state): + state = bool(state) + + if state != self._hierarchy_view: + self._hierarchy_view = state + + def _is_outdated(self, row, parent): + """Return whether row is outdated. + + A row is considered outdated if it has "version" and "highest_version" + data and in the internal data structure, and they are not of an + equal value. + + """ + def outdated(node): + version = node.get("version", None) + highest = node.get("highest_version", None) + + # Always allow indices that have no version data at all + if version is None and highest is None: + return True + + # If either a version or highest is present but not the other + # consider the item invalid. + if not self._hierarchy_view: + # Skip this check if in hierarchy view, or the child item + # node will be hidden even it's actually outdated. + if version is None or highest is None: + return False + return version != highest + + index = self.sourceModel().index(row, self.filterKeyColumn(), parent) + + # The scene contents are grouped by "representation", e.g. the same + # "representation" loaded twice is grouped under the same header. + # Since the version check filters these parent groups we skip that + # check for the individual children. + has_parent = index.parent().isValid() + if has_parent and not self._hierarchy_view: + return True + + # Filter to those that have the different version numbers + node = index.internalPointer() + if outdated(node): + return True + + if self._hierarchy_view: + for _node in walk_hierarchy(node): + if outdated(_node): + return True + + return False + + def _matches(self, row, parent, pattern): + """Return whether row matches regex pattern. + + Args: + row (int): row number in model + parent (QtCore.QModelIndex): parent index + pattern (regex.pattern): pattern to check for in key + + Returns: + bool + + """ + model = self.sourceModel() + column = self.filterKeyColumn() + role = self.filterRole() + + def matches(row, parent, pattern): + index = model.index(row, column, parent) + key = model.data(index, role) + if re.search(pattern, key, re.IGNORECASE): + return True + + if matches(row, parent, pattern): + return True + + # Also allow if any of the children matches + source_index = model.index(row, column, parent) + rows = model.rowCount(source_index) + + if any( + matches(idx, source_index, pattern) + for idx in range(rows) + ): + return True + + if not self._hierarchy_view: + return False + + for idx in range(rows): + child_index = model.index(idx, column, source_index) + child_rows = model.rowCount(child_index) + return any( + self._matches(child_idx, child_index, pattern) + for child_idx in range(child_rows) + ) + + return True diff --git a/openpype/tools/ayon_sceneinventory/models/__init__.py b/openpype/tools/ayon_sceneinventory/models/__init__.py new file mode 100644 index 0000000000..c861d3c1a0 --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/models/__init__.py @@ -0,0 +1,6 @@ +from .site_sync import SiteSyncModel + + +__all__ = ( + "SiteSyncModel", +) diff --git a/openpype/tools/ayon_sceneinventory/models/site_sync.py b/openpype/tools/ayon_sceneinventory/models/site_sync.py new file mode 100644 index 0000000000..b8c9443230 --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/models/site_sync.py @@ -0,0 +1,176 @@ +from openpype.client import get_representations +from openpype.modules import ModulesManager + +NOT_SET = object() + + +class SiteSyncModel: + def __init__(self, controller): + self._controller = controller + + self._sync_server_module = NOT_SET + self._sync_server_enabled = None + self._active_site = NOT_SET + self._remote_site = NOT_SET + self._active_site_provider = NOT_SET + self._remote_site_provider = NOT_SET + + def reset(self): + self._sync_server_module = NOT_SET + self._sync_server_enabled = None + self._active_site = NOT_SET + self._remote_site = NOT_SET + self._active_site_provider = NOT_SET + self._remote_site_provider = NOT_SET + + def is_sync_server_enabled(self): + """Site sync is enabled. + + Returns: + bool: Is enabled or not. + """ + + self._cache_sync_server_module() + return self._sync_server_enabled + + def get_site_provider_icons(self): + """Icon paths per provider. + + Returns: + dict[str, str]: Path by provider name. + """ + + site_sync = self._get_sync_server_module() + if site_sync is None: + return {} + return site_sync.get_site_icons() + + def get_sites_information(self): + return { + "active_site": self._get_active_site(), + "active_site_provider": self._get_active_site_provider(), + "remote_site": self._get_remote_site(), + "remote_site_provider": self._get_remote_site_provider() + } + + def get_representations_site_progress(self, representation_ids): + """Get progress of representations sync.""" + + representation_ids = set(representation_ids) + output = { + repre_id: { + "active_site": 0, + "remote_site": 0, + } + for repre_id in representation_ids + } + if not self.is_sync_server_enabled(): + return output + + project_name = self._controller.get_current_project_name() + site_sync = self._get_sync_server_module() + repre_docs = get_representations(project_name, representation_ids) + active_site = self._get_active_site() + remote_site = self._get_remote_site() + + for repre_doc in repre_docs: + repre_output = output[repre_doc["_id"]] + result = site_sync.get_progress_for_repre( + repre_doc, active_site, remote_site + ) + repre_output["active_site"] = result[active_site] + repre_output["remote_site"] = result[remote_site] + + return output + + def resync_representations(self, representation_ids, site_type): + """ + + Args: + representation_ids (Iterable[str]): Representation ids. + site_type (Literal[active_site, remote_site]): Site type. + """ + + project_name = self._controller.get_current_project_name() + site_sync = self._get_sync_server_module() + active_site = self._get_active_site() + remote_site = self._get_remote_site() + progress = self.get_representations_site_progress( + representation_ids + ) + for repre_id in representation_ids: + repre_progress = progress.get(repre_id) + if not repre_progress: + continue + + if site_type == "active_site": + # check opposite from added site, must be 1 or unable to sync + check_progress = repre_progress["remote_site"] + site = active_site + else: + check_progress = repre_progress["active_site"] + site = remote_site + + if check_progress == 1: + site_sync.add_site( + project_name, repre_id, site, force=True + ) + + def _get_sync_server_module(self): + self._cache_sync_server_module() + return self._sync_server_module + + def _cache_sync_server_module(self): + if self._sync_server_module is not NOT_SET: + return self._sync_server_module + manager = ModulesManager() + site_sync = manager.modules_by_name.get("sync_server") + sync_enabled = site_sync is not None and site_sync.enabled + self._sync_server_module = site_sync + self._sync_server_enabled = sync_enabled + + def _get_active_site(self): + if self._active_site is NOT_SET: + self._cache_sites() + return self._active_site + + def _get_remote_site(self): + if self._remote_site is NOT_SET: + self._cache_sites() + return self._remote_site + + def _get_active_site_provider(self): + if self._active_site_provider is NOT_SET: + self._cache_sites() + return self._active_site_provider + + def _get_remote_site_provider(self): + if self._remote_site_provider is NOT_SET: + self._cache_sites() + return self._remote_site_provider + + def _cache_sites(self): + site_sync = self._get_sync_server_module() + active_site = None + remote_site = None + active_site_provider = None + remote_site_provider = None + if site_sync is not None: + project_name = self._controller.get_current_project_name() + active_site = site_sync.get_active_site(project_name) + remote_site = site_sync.get_remote_site(project_name) + active_site_provider = "studio" + remote_site_provider = "studio" + if active_site != "studio": + active_site_provider = site_sync.get_active_provider( + project_name, active_site + ) + if remote_site != "studio": + remote_site_provider = site_sync.get_active_provider( + project_name, remote_site + ) + + self._active_site = active_site + self._remote_site = remote_site + self._active_site_provider = active_site_provider + self._remote_site_provider = remote_site_provider diff --git a/openpype/tools/ayon_sceneinventory/switch_dialog/__init__.py b/openpype/tools/ayon_sceneinventory/switch_dialog/__init__.py new file mode 100644 index 0000000000..4c07832829 --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/switch_dialog/__init__.py @@ -0,0 +1,6 @@ +from .dialog import SwitchAssetDialog + + +__all__ = ( + "SwitchAssetDialog", +) diff --git a/openpype/tools/ayon_sceneinventory/switch_dialog/dialog.py b/openpype/tools/ayon_sceneinventory/switch_dialog/dialog.py new file mode 100644 index 0000000000..2ebed7f89b --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/switch_dialog/dialog.py @@ -0,0 +1,1333 @@ +import collections +import logging + +from qtpy import QtWidgets, QtCore +import qtawesome + +from openpype.client import ( + get_assets, + get_subset_by_name, + get_subsets, + get_versions, + get_hero_versions, + get_last_versions, + get_representations, +) +from openpype.pipeline.load import ( + discover_loader_plugins, + switch_container, + get_repres_contexts, + loaders_from_repre_context, + LoaderSwitchNotImplementedError, + IncompatibleLoaderError, + LoaderNotFoundError +) + +from .widgets import ( + ButtonWithMenu, + SearchComboBox +) +from .folders_input import FoldersField + +log = logging.getLogger("SwitchAssetDialog") + + +class ValidationState: + def __init__(self): + self.folder_ok = True + self.product_ok = True + self.repre_ok = True + + @property + def all_ok(self): + return ( + self.folder_ok + and self.product_ok + and self.repre_ok + ) + + +class SwitchAssetDialog(QtWidgets.QDialog): + """Widget to support asset switching""" + + MIN_WIDTH = 550 + + switched = QtCore.Signal() + + def __init__(self, controller, parent=None, items=None): + super(SwitchAssetDialog, self).__init__(parent) + + self.setWindowTitle("Switch selected items ...") + + # Force and keep focus dialog + self.setModal(True) + + folders_field = FoldersField(controller, self) + products_combox = SearchComboBox(self) + repres_combobox = SearchComboBox(self) + + products_combox.set_placeholder("") + repres_combobox.set_placeholder("") + + folder_label = QtWidgets.QLabel(self) + product_label = QtWidgets.QLabel(self) + repre_label = QtWidgets.QLabel(self) + + current_folder_btn = QtWidgets.QPushButton("Use current folder", self) + + accept_icon = qtawesome.icon("fa.check", color="white") + accept_btn = ButtonWithMenu(self) + accept_btn.setIcon(accept_icon) + + main_layout = QtWidgets.QGridLayout(self) + # Folder column + main_layout.addWidget(current_folder_btn, 0, 0) + main_layout.addWidget(folders_field, 1, 0) + main_layout.addWidget(folder_label, 2, 0) + # Product column + main_layout.addWidget(products_combox, 1, 1) + main_layout.addWidget(product_label, 2, 1) + # Representation column + main_layout.addWidget(repres_combobox, 1, 2) + main_layout.addWidget(repre_label, 2, 2) + # Btn column + main_layout.addWidget(accept_btn, 1, 3) + main_layout.setColumnStretch(0, 1) + main_layout.setColumnStretch(1, 1) + main_layout.setColumnStretch(2, 1) + main_layout.setColumnStretch(3, 0) + + show_timer = QtCore.QTimer() + show_timer.setInterval(0) + show_timer.setSingleShot(False) + + show_timer.timeout.connect(self._on_show_timer) + folders_field.value_changed.connect( + self._combobox_value_changed + ) + products_combox.currentIndexChanged.connect( + self._combobox_value_changed + ) + repres_combobox.currentIndexChanged.connect( + self._combobox_value_changed + ) + accept_btn.clicked.connect(self._on_accept) + current_folder_btn.clicked.connect(self._on_current_folder) + + self._show_timer = show_timer + self._show_counter = 0 + + self._current_folder_btn = current_folder_btn + + self._folders_field = folders_field + self._products_combox = products_combox + self._representations_box = repres_combobox + + self._folder_label = folder_label + self._product_label = product_label + self._repre_label = repre_label + + self._accept_btn = accept_btn + + self.setMinimumWidth(self.MIN_WIDTH) + + # Set default focus to accept button so you don't directly type in + # first asset field, this also allows to see the placeholder value. + accept_btn.setFocus() + + self._folder_docs_by_id = {} + self._product_docs_by_id = {} + self._version_docs_by_id = {} + self._repre_docs_by_id = {} + + self._missing_folder_ids = set() + self._missing_product_ids = set() + self._missing_version_ids = set() + self._missing_repre_ids = set() + self._missing_docs = False + + self._inactive_folder_ids = set() + self._inactive_product_ids = set() + self._inactive_repre_ids = set() + + self._init_folder_id = None + self._init_product_name = None + self._init_repre_name = None + + self._fill_check = False + + self._project_name = controller.get_current_project_name() + self._folder_id = controller.get_current_folder_id() + + self._current_folder_btn.setEnabled(self._folder_id is not None) + + self._controller = controller + + self._items = items + self._prepare_content_data() + + def showEvent(self, event): + super(SwitchAssetDialog, self).showEvent(event) + self._show_timer.start() + + def refresh(self, init_refresh=False): + """Build the need comboboxes with content""" + if not self._fill_check and not init_refresh: + return + + self._fill_check = False + + validation_state = ValidationState() + self._folders_field.refresh() + # Set other comboboxes to empty if any document is missing or + # any folder of loaded representations is archived. + self._is_folder_ok(validation_state) + if validation_state.folder_ok: + product_values = self._get_product_box_values() + self._fill_combobox(product_values, "product") + self._is_product_ok(validation_state) + + if validation_state.folder_ok and validation_state.product_ok: + repre_values = sorted(self._representations_box_values()) + self._fill_combobox(repre_values, "repre") + self._is_repre_ok(validation_state) + + # Fill comboboxes with values + self.set_labels() + + self.apply_validations(validation_state) + + self._build_loaders_menu() + + if init_refresh: + # pre select context if possible + self._folders_field.set_selected_item(self._init_folder_id) + self._products_combox.set_valid_value(self._init_product_name) + self._representations_box.set_valid_value(self._init_repre_name) + + self._fill_check = True + + def set_labels(self): + folder_label = self._folders_field.get_selected_folder_label() + product_label = self._products_combox.get_valid_value() + repre_label = self._representations_box.get_valid_value() + + default = "*No changes" + self._folder_label.setText(folder_label or default) + self._product_label.setText(product_label or default) + self._repre_label.setText(repre_label or default) + + def apply_validations(self, validation_state): + error_msg = "*Please select" + error_sheet = "border: 1px solid red;" + + product_sheet = None + repre_sheet = None + accept_state = "" + if validation_state.folder_ok is False: + self._folder_label.setText(error_msg) + elif validation_state.product_ok is False: + product_sheet = error_sheet + self._product_label.setText(error_msg) + elif validation_state.repre_ok is False: + repre_sheet = error_sheet + self._repre_label.setText(error_msg) + + if validation_state.all_ok: + accept_state = "1" + + self._folders_field.set_valid(validation_state.folder_ok) + self._products_combox.setStyleSheet(product_sheet or "") + self._representations_box.setStyleSheet(repre_sheet or "") + + self._accept_btn.setEnabled(validation_state.all_ok) + self._set_style_property(self._accept_btn, "state", accept_state) + + def find_last_versions(self, product_ids): + project_name = self._project_name + return get_last_versions( + project_name, + subset_ids=product_ids, + fields=["_id", "parent", "type"] + ) + + def _on_show_timer(self): + if self._show_counter == 2: + self._show_timer.stop() + self.refresh(True) + else: + self._show_counter += 1 + + def _prepare_content_data(self): + repre_ids = { + item["representation"] + for item in self._items + } + + project_name = self._project_name + repres = list(get_representations( + project_name, + representation_ids=repre_ids, + archived=True, + )) + repres_by_id = {str(repre["_id"]): repre for repre in repres} + + content_repre_docs_by_id = {} + inactive_repre_ids = set() + missing_repre_ids = set() + version_ids = set() + for repre_id in repre_ids: + repre_doc = repres_by_id.get(repre_id) + if repre_doc is None: + missing_repre_ids.add(repre_id) + elif repres_by_id[repre_id]["type"] == "archived_representation": + inactive_repre_ids.add(repre_id) + version_ids.add(repre_doc["parent"]) + else: + content_repre_docs_by_id[repre_id] = repre_doc + version_ids.add(repre_doc["parent"]) + + version_docs = get_versions( + project_name, + version_ids=version_ids, + hero=True + ) + content_version_docs_by_id = {} + for version_doc in version_docs: + version_id = version_doc["_id"] + content_version_docs_by_id[version_id] = version_doc + + missing_version_ids = set() + product_ids = set() + for version_id in version_ids: + version_doc = content_version_docs_by_id.get(version_id) + if version_doc is None: + missing_version_ids.add(version_id) + else: + product_ids.add(version_doc["parent"]) + + product_docs = get_subsets( + project_name, subset_ids=product_ids, archived=True + ) + product_docs_by_id = {sub["_id"]: sub for sub in product_docs} + + folder_ids = set() + inactive_product_ids = set() + missing_product_ids = set() + content_product_docs_by_id = {} + for product_id in product_ids: + product_doc = product_docs_by_id.get(product_id) + if product_doc is None: + missing_product_ids.add(product_id) + elif product_doc["type"] == "archived_subset": + folder_ids.add(product_doc["parent"]) + inactive_product_ids.add(product_id) + else: + folder_ids.add(product_doc["parent"]) + content_product_docs_by_id[product_id] = product_doc + + folder_docs = get_assets( + project_name, asset_ids=folder_ids, archived=True + ) + folder_docs_by_id = { + folder_doc["_id"]: folder_doc + for folder_doc in folder_docs + } + + missing_folder_ids = set() + inactive_folder_ids = set() + content_folder_docs_by_id = {} + for folder_id in folder_ids: + folder_doc = folder_docs_by_id.get(folder_id) + if folder_doc is None: + missing_folder_ids.add(folder_id) + elif folder_doc["type"] == "archived_asset": + inactive_folder_ids.add(folder_id) + else: + content_folder_docs_by_id[folder_id] = folder_doc + + # stash context values, works only for single representation + init_folder_id = None + init_product_name = None + init_repre_name = None + if len(repres) == 1: + init_repre_doc = repres[0] + init_version_doc = content_version_docs_by_id.get( + init_repre_doc["parent"]) + init_product_doc = None + init_folder_doc = None + if init_version_doc: + init_product_doc = content_product_docs_by_id.get( + init_version_doc["parent"] + ) + if init_product_doc: + init_folder_doc = content_folder_docs_by_id.get( + init_product_doc["parent"] + ) + if init_folder_doc: + init_repre_name = init_repre_doc["name"] + init_product_name = init_product_doc["name"] + init_folder_id = init_folder_doc["_id"] + + self._init_folder_id = init_folder_id + self._init_product_name = init_product_name + self._init_repre_name = init_repre_name + + self._folder_docs_by_id = content_folder_docs_by_id + self._product_docs_by_id = content_product_docs_by_id + self._version_docs_by_id = content_version_docs_by_id + self._repre_docs_by_id = content_repre_docs_by_id + + self._missing_folder_ids = missing_folder_ids + self._missing_product_ids = missing_product_ids + self._missing_version_ids = missing_version_ids + self._missing_repre_ids = missing_repre_ids + self._missing_docs = ( + bool(missing_folder_ids) + or bool(missing_version_ids) + or bool(missing_product_ids) + or bool(missing_repre_ids) + ) + + self._inactive_folder_ids = inactive_folder_ids + self._inactive_product_ids = inactive_product_ids + self._inactive_repre_ids = inactive_repre_ids + + def _combobox_value_changed(self, *args, **kwargs): + self.refresh() + + def _build_loaders_menu(self): + repre_ids = self._get_current_output_repre_ids() + loaders = self._get_loaders(repre_ids) + # Get and destroy the action group + self._accept_btn.clear_actions() + + if not loaders: + return + + # Build new action group + group = QtWidgets.QActionGroup(self._accept_btn) + + for loader in loaders: + # Label + label = getattr(loader, "label", None) + if label is None: + label = loader.__name__ + + action = group.addAction(label) + # action = QtWidgets.QAction(label) + action.setData(loader) + + # Support font-awesome icons using the `.icon` and `.color` + # attributes on plug-ins. + icon = getattr(loader, "icon", None) + if icon is not None: + try: + key = "fa.{0}".format(icon) + color = getattr(loader, "color", "white") + action.setIcon(qtawesome.icon(key, color=color)) + + except Exception as exc: + print("Unable to set icon for loader {}: {}".format( + loader, str(exc) + )) + + self._accept_btn.add_action(action) + + group.triggered.connect(self._on_action_clicked) + + def _on_action_clicked(self, action): + loader_plugin = action.data() + self._trigger_switch(loader_plugin) + + def _get_loaders(self, repre_ids): + repre_contexts = None + if repre_ids: + repre_contexts = get_repres_contexts(repre_ids) + + if not repre_contexts: + return list() + + available_loaders = [] + for loader_plugin in discover_loader_plugins(): + # Skip loaders without switch method + if not hasattr(loader_plugin, "switch"): + continue + + # Skip utility loaders + if ( + hasattr(loader_plugin, "is_utility") + and loader_plugin.is_utility + ): + continue + available_loaders.append(loader_plugin) + + loaders = None + for repre_context in repre_contexts.values(): + _loaders = set(loaders_from_repre_context( + available_loaders, repre_context + )) + if loaders is None: + loaders = _loaders + else: + loaders = _loaders.intersection(loaders) + + if not loaders: + break + + if loaders is None: + loaders = [] + else: + loaders = list(loaders) + + return loaders + + def _fill_combobox(self, values, combobox_type): + if combobox_type == "product": + combobox_widget = self._products_combox + elif combobox_type == "repre": + combobox_widget = self._representations_box + else: + return + selected_value = combobox_widget.get_valid_value() + + # Fill combobox + if values is not None: + combobox_widget.populate(list(sorted(values))) + if selected_value and selected_value in values: + index = None + for idx in range(combobox_widget.count()): + if selected_value == str(combobox_widget.itemText(idx)): + index = idx + break + if index is not None: + combobox_widget.setCurrentIndex(index) + + def _set_style_property(self, widget, name, value): + cur_value = widget.property(name) + if cur_value == value: + return + widget.setProperty(name, value) + widget.style().polish(widget) + + def _get_current_output_repre_ids(self): + # NOTE hero versions are not used because it is expected that + # hero version has same representations as latests + selected_folder_id = self._folders_field.get_selected_folder_id() + selected_product_name = self._products_combox.currentText() + selected_repre = self._representations_box.currentText() + + # Nothing is selected + # [ ] [ ] [ ] + if ( + not selected_folder_id + and not selected_product_name + and not selected_repre + ): + return list(self._repre_docs_by_id.keys()) + + # Everything is selected + # [x] [x] [x] + if selected_folder_id and selected_product_name and selected_repre: + return self._get_current_output_repre_ids_xxx( + selected_folder_id, selected_product_name, selected_repre + ) + + # [x] [x] [ ] + # If folder and product is selected + if selected_folder_id and selected_product_name: + return self._get_current_output_repre_ids_xxo( + selected_folder_id, selected_product_name + ) + + # [x] [ ] [x] + # If folder and repre is selected + if selected_folder_id and selected_repre: + return self._get_current_output_repre_ids_xox( + selected_folder_id, selected_repre + ) + + # [x] [ ] [ ] + # If folder and product is selected + if selected_folder_id: + return self._get_current_output_repre_ids_xoo(selected_folder_id) + + # [ ] [x] [x] + if selected_product_name and selected_repre: + return self._get_current_output_repre_ids_oxx( + selected_product_name, selected_repre + ) + + # [ ] [x] [ ] + if selected_product_name: + return self._get_current_output_repre_ids_oxo( + selected_product_name + ) + + # [ ] [ ] [x] + return self._get_current_output_repre_ids_oox(selected_repre) + + def _get_current_output_repre_ids_xxx( + self, folder_id, selected_product_name, selected_repre + ): + project_name = self._project_name + product_doc = get_subset_by_name( + project_name, + selected_product_name, + folder_id, + fields=["_id"] + ) + + product_id = product_doc["_id"] + last_versions_by_product_id = self.find_last_versions([product_id]) + version_doc = last_versions_by_product_id.get(product_id) + if not version_doc: + return [] + + repre_docs = get_representations( + project_name, + version_ids=[version_doc["_id"]], + representation_names=[selected_repre], + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_xxo(self, folder_id, product_name): + project_name = self._project_name + product_doc = get_subset_by_name( + project_name, + product_name, + folder_id, + fields=["_id"] + ) + if not product_doc: + return [] + + repre_names = set() + for repre_doc in self._repre_docs_by_id.values(): + repre_names.add(repre_doc["name"]) + + # TODO where to take version ids? + version_ids = [] + repre_docs = get_representations( + project_name, + representation_names=repre_names, + version_ids=version_ids, + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_xox(self, folder_id, selected_repre): + product_names = { + product_doc["name"] + for product_doc in self._product_docs_by_id.values() + } + + project_name = self._project_name + product_docs = get_subsets( + project_name, + asset_ids=[folder_id], + subset_names=product_names, + fields=["_id", "name"] + ) + product_name_by_id = { + product_doc["_id"]: product_doc["name"] + for product_doc in product_docs + } + product_ids = list(product_name_by_id.keys()) + last_versions_by_product_id = self.find_last_versions(product_ids) + last_version_id_by_product_name = {} + for product_id, last_version in last_versions_by_product_id.items(): + product_name = product_name_by_id[product_id] + last_version_id_by_product_name[product_name] = ( + last_version["_id"] + ) + + repre_docs = get_representations( + project_name, + version_ids=last_version_id_by_product_name.values(), + representation_names=[selected_repre], + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_xoo(self, folder_id): + project_name = self._project_name + repres_by_product_name = collections.defaultdict(set) + for repre_doc in self._repre_docs_by_id.values(): + version_doc = self._version_docs_by_id[repre_doc["parent"]] + product_doc = self._product_docs_by_id[version_doc["parent"]] + product_name = product_doc["name"] + repres_by_product_name[product_name].add(repre_doc["name"]) + + product_docs = list(get_subsets( + project_name, + asset_ids=[folder_id], + subset_names=repres_by_product_name.keys(), + fields=["_id", "name"] + )) + product_name_by_id = { + product_doc["_id"]: product_doc["name"] + for product_doc in product_docs + } + product_ids = list(product_name_by_id.keys()) + last_versions_by_product_id = self.find_last_versions(product_ids) + last_version_id_by_product_name = {} + for product_id, last_version in last_versions_by_product_id.items(): + product_name = product_name_by_id[product_id] + last_version_id_by_product_name[product_name] = ( + last_version["_id"] + ) + + repre_names_by_version_id = {} + for product_name, repre_names in repres_by_product_name.items(): + version_id = last_version_id_by_product_name.get(product_name) + # This should not happen but why to crash? + if version_id is not None: + repre_names_by_version_id[version_id] = list(repre_names) + + repre_docs = get_representations( + project_name, + names_by_version_ids=repre_names_by_version_id, + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_oxx( + self, product_name, selected_repre + ): + project_name = self._project_name + product_docs = get_subsets( + project_name, + asset_ids=self._folder_docs_by_id.keys(), + subset_names=[product_name], + fields=["_id"] + ) + product_ids = [product_doc["_id"] for product_doc in product_docs] + last_versions_by_product_id = self.find_last_versions(product_ids) + last_version_ids = [ + last_version["_id"] + for last_version in last_versions_by_product_id.values() + ] + repre_docs = get_representations( + project_name, + version_ids=last_version_ids, + representation_names=[selected_repre], + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_oxo(self, product_name): + project_name = self._project_name + product_docs = get_subsets( + project_name, + asset_ids=self._folder_docs_by_id.keys(), + subset_names=[product_name], + fields=["_id", "parent"] + ) + product_docs_by_id = { + product_doc["_id"]: product_doc + for product_doc in product_docs + } + if not product_docs: + return list() + + last_versions_by_product_id = self.find_last_versions( + product_docs_by_id.keys() + ) + + product_id_by_version_id = {} + for product_id, last_version in last_versions_by_product_id.items(): + version_id = last_version["_id"] + product_id_by_version_id[version_id] = product_id + + if not product_id_by_version_id: + return list() + + repre_names_by_folder_id = collections.defaultdict(set) + for repre_doc in self._repre_docs_by_id.values(): + version_doc = self._version_docs_by_id[repre_doc["parent"]] + product_doc = self._product_docs_by_id[version_doc["parent"]] + folder_doc = self._folder_docs_by_id[product_doc["parent"]] + folder_id = folder_doc["_id"] + repre_names_by_folder_id[folder_id].add(repre_doc["name"]) + + repre_names_by_version_id = {} + for last_version_id, product_id in product_id_by_version_id.items(): + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + repre_names = repre_names_by_folder_id.get(folder_id) + if not repre_names: + continue + repre_names_by_version_id[last_version_id] = repre_names + + repre_docs = get_representations( + project_name, + names_by_version_ids=repre_names_by_version_id, + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_oox(self, selected_repre): + project_name = self._project_name + repre_docs = get_representations( + project_name, + representation_names=[selected_repre], + version_ids=self._version_docs_by_id.keys(), + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_product_box_values(self): + project_name = self._project_name + selected_folder_id = self._folders_field.get_selected_folder_id() + if selected_folder_id: + folder_ids = [selected_folder_id] + else: + folder_ids = list(self._folder_docs_by_id.keys()) + + product_docs = get_subsets( + project_name, + asset_ids=folder_ids, + fields=["parent", "name"] + ) + + product_names_by_parent_id = collections.defaultdict(set) + for product_doc in product_docs: + product_names_by_parent_id[product_doc["parent"]].add( + product_doc["name"] + ) + + possible_product_names = None + for product_names in product_names_by_parent_id.values(): + if possible_product_names is None: + possible_product_names = product_names + else: + possible_product_names = possible_product_names.intersection( + product_names) + + if not possible_product_names: + break + + if not possible_product_names: + return [] + return list(possible_product_names) + + def _representations_box_values(self): + # NOTE hero versions are not used because it is expected that + # hero version has same representations as latests + project_name = self._project_name + selected_folder_id = self._folders_field.get_selected_folder_id() + selected_product_name = self._products_combox.currentText() + + # If nothing is selected + # [ ] [ ] [?] + if not selected_folder_id and not selected_product_name: + # Find all representations of selection's products + possible_repres = get_representations( + project_name, + version_ids=self._version_docs_by_id.keys(), + fields=["parent", "name"] + ) + + possible_repres_by_parent = collections.defaultdict(set) + for repre in possible_repres: + possible_repres_by_parent[repre["parent"]].add(repre["name"]) + + output_repres = None + for repre_names in possible_repres_by_parent.values(): + if output_repres is None: + output_repres = repre_names + else: + output_repres = (output_repres & repre_names) + + if not output_repres: + break + + return list(output_repres or list()) + + # [x] [x] [?] + if selected_folder_id and selected_product_name: + product_doc = get_subset_by_name( + project_name, + selected_product_name, + selected_folder_id, + fields=["_id"] + ) + + product_id = product_doc["_id"] + last_versions_by_product_id = self.find_last_versions([product_id]) + version_doc = last_versions_by_product_id.get(product_id) + repre_docs = get_representations( + project_name, + version_ids=[version_doc["_id"]], + fields=["name"] + ) + return [ + repre_doc["name"] + for repre_doc in repre_docs + ] + + # [x] [ ] [?] + # If only folder is selected + if selected_folder_id: + # Filter products by names from content + product_names = { + product_doc["name"] + for product_doc in self._product_docs_by_id.values() + } + + product_docs = get_subsets( + project_name, + asset_ids=[selected_folder_id], + subset_names=product_names, + fields=["_id"] + ) + product_ids = { + product_doc["_id"] + for product_doc in product_docs + } + if not product_ids: + return list() + + last_versions_by_product_id = self.find_last_versions(product_ids) + product_id_by_version_id = {} + for product_id, last_version in ( + last_versions_by_product_id.items() + ): + version_id = last_version["_id"] + product_id_by_version_id[version_id] = product_id + + if not product_id_by_version_id: + return list() + + repre_docs = list(get_representations( + project_name, + version_ids=product_id_by_version_id.keys(), + fields=["name", "parent"] + )) + if not repre_docs: + return list() + + repre_names_by_parent = collections.defaultdict(set) + for repre_doc in repre_docs: + repre_names_by_parent[repre_doc["parent"]].add( + repre_doc["name"] + ) + + available_repres = None + for repre_names in repre_names_by_parent.values(): + if available_repres is None: + available_repres = repre_names + continue + + available_repres = available_repres.intersection(repre_names) + + return list(available_repres) + + # [ ] [x] [?] + product_docs = list(get_subsets( + project_name, + asset_ids=self._folder_docs_by_id.keys(), + subset_names=[selected_product_name], + fields=["_id", "parent"] + )) + if not product_docs: + return list() + + product_docs_by_id = { + product_doc["_id"]: product_doc + for product_doc in product_docs + } + last_versions_by_product_id = self.find_last_versions( + product_docs_by_id.keys() + ) + + product_id_by_version_id = {} + for product_id, last_version in last_versions_by_product_id.items(): + version_id = last_version["_id"] + product_id_by_version_id[version_id] = product_id + + if not product_id_by_version_id: + return list() + + repre_docs = list( + get_representations( + project_name, + version_ids=product_id_by_version_id.keys(), + fields=["name", "parent"] + ) + ) + if not repre_docs: + return list() + + repre_names_by_folder_id = collections.defaultdict(set) + for repre_doc in repre_docs: + product_id = product_id_by_version_id[repre_doc["parent"]] + folder_id = product_docs_by_id[product_id]["parent"] + repre_names_by_folder_id[folder_id].add(repre_doc["name"]) + + available_repres = None + for repre_names in repre_names_by_folder_id.values(): + if available_repres is None: + available_repres = repre_names + continue + + available_repres = available_repres.intersection(repre_names) + + return list(available_repres) + + def _is_folder_ok(self, validation_state): + selected_folder_id = self._folders_field.get_selected_folder_id() + if ( + selected_folder_id is None + and (self._missing_docs or self._inactive_folder_ids) + ): + validation_state.folder_ok = False + + def _is_product_ok(self, validation_state): + selected_folder_id = self._folders_field.get_selected_folder_id() + selected_product_name = self._products_combox.get_valid_value() + + # [?] [x] [?] + # If product is selected then must be ok + if selected_product_name is not None: + return + + # [ ] [ ] [?] + if selected_folder_id is None: + # If there were archived products and folder is not selected + if self._inactive_product_ids: + validation_state.product_ok = False + return + + # [x] [ ] [?] + project_name = self._project_name + product_docs = get_subsets( + project_name, asset_ids=[selected_folder_id], fields=["name"] + ) + + product_names = set( + product_doc["name"] + for product_doc in product_docs + ) + + for product_doc in self._product_docs_by_id.values(): + if product_doc["name"] not in product_names: + validation_state.product_ok = False + break + + def _is_repre_ok(self, validation_state): + selected_folder_id = self._folders_field.get_selected_folder_id() + selected_product_name = self._products_combox.get_valid_value() + selected_repre = self._representations_box.get_valid_value() + + # [?] [?] [x] + # If product is selected then must be ok + if selected_repre is not None: + return + + # [ ] [ ] [ ] + if selected_folder_id is None and selected_product_name is None: + if ( + self._inactive_repre_ids + or self._missing_version_ids + or self._missing_repre_ids + ): + validation_state.repre_ok = False + return + + # [x] [x] [ ] + project_name = self._project_name + if ( + selected_folder_id is not None + and selected_product_name is not None + ): + product_doc = get_subset_by_name( + project_name, + selected_product_name, + selected_folder_id, + fields=["_id"] + ) + product_id = product_doc["_id"] + last_versions_by_product_id = self.find_last_versions([product_id]) + last_version = last_versions_by_product_id.get(product_id) + if not last_version: + validation_state.repre_ok = False + return + + repre_docs = get_representations( + project_name, + version_ids=[last_version["_id"]], + fields=["name"] + ) + + repre_names = set( + repre_doc["name"] + for repre_doc in repre_docs + ) + for repre_doc in self._repre_docs_by_id.values(): + if repre_doc["name"] not in repre_names: + validation_state.repre_ok = False + break + return + + # [x] [ ] [ ] + if selected_folder_id is not None: + product_docs = list(get_subsets( + project_name, + asset_ids=[selected_folder_id], + fields=["_id", "name"] + )) + + product_name_by_id = {} + product_ids = set() + for product_doc in product_docs: + product_id = product_doc["_id"] + product_ids.add(product_id) + product_name_by_id[product_id] = product_doc["name"] + + last_versions_by_product_id = self.find_last_versions(product_ids) + + product_id_by_version_id = {} + for product_id, last_version in ( + last_versions_by_product_id.items() + ): + version_id = last_version["_id"] + product_id_by_version_id[version_id] = product_id + + repre_docs = get_representations( + project_name, + version_ids=product_id_by_version_id.keys(), + fields=["name", "parent"] + ) + repres_by_product_name = collections.defaultdict(set) + for repre_doc in repre_docs: + product_id = product_id_by_version_id[repre_doc["parent"]] + product_name = product_name_by_id[product_id] + repres_by_product_name[product_name].add(repre_doc["name"]) + + for repre_doc in self._repre_docs_by_id.values(): + version_doc = self._version_docs_by_id[repre_doc["parent"]] + product_doc = self._product_docs_by_id[version_doc["parent"]] + repre_names = repres_by_product_name[product_doc["name"]] + if repre_doc["name"] not in repre_names: + validation_state.repre_ok = False + break + return + + # [ ] [x] [ ] + # Product documents + product_docs = get_subsets( + project_name, + asset_ids=self._folder_docs_by_id.keys(), + subset_names=[selected_product_name], + fields=["_id", "name", "parent"] + ) + product_docs_by_id = {} + for product_doc in product_docs: + product_docs_by_id[product_doc["_id"]] = product_doc + + last_versions_by_product_id = self.find_last_versions( + product_docs_by_id.keys() + ) + product_id_by_version_id = {} + for product_id, last_version in last_versions_by_product_id.items(): + version_id = last_version["_id"] + product_id_by_version_id[version_id] = product_id + + repre_docs = get_representations( + project_name, + version_ids=product_id_by_version_id.keys(), + fields=["name", "parent"] + ) + repres_by_folder_id = collections.defaultdict(set) + for repre_doc in repre_docs: + product_id = product_id_by_version_id[repre_doc["parent"]] + folder_id = product_docs_by_id[product_id]["parent"] + repres_by_folder_id[folder_id].add(repre_doc["name"]) + + for repre_doc in self._repre_docs_by_id.values(): + version_doc = self._version_docs_by_id[repre_doc["parent"]] + product_doc = self._product_docs_by_id[version_doc["parent"]] + folder_id = product_doc["parent"] + repre_names = repres_by_folder_id[folder_id] + if repre_doc["name"] not in repre_names: + validation_state.repre_ok = False + break + + def _on_current_folder(self): + # Set initial folder as current. + folder_id = self._controller.get_current_folder_id() + if not folder_id: + return + + selected_folder_id = self._folders_field.get_selected_folder_id() + if folder_id == selected_folder_id: + return + + self._folders_field.set_selected_item(folder_id) + self._combobox_value_changed() + + def _on_accept(self): + self._trigger_switch() + + def _trigger_switch(self, loader=None): + # Use None when not a valid value or when placeholder value + selected_folder_id = self._folders_field.get_selected_folder_id() + selected_product_name = self._products_combox.get_valid_value() + selected_representation = self._representations_box.get_valid_value() + + project_name = self._project_name + if selected_folder_id: + folder_ids = {selected_folder_id} + else: + folder_ids = set(self._folder_docs_by_id.keys()) + + product_names = None + if selected_product_name: + product_names = [selected_product_name] + + product_docs = list(get_subsets( + project_name, + subset_names=product_names, + asset_ids=folder_ids + )) + product_ids = set() + product_docs_by_parent_and_name = collections.defaultdict(dict) + for product_doc in product_docs: + product_ids.add(product_doc["_id"]) + folder_id = product_doc["parent"] + name = product_doc["name"] + product_docs_by_parent_and_name[folder_id][name] = product_doc + + # versions + _version_docs = get_versions(project_name, subset_ids=product_ids) + version_docs = list(reversed( + sorted(_version_docs, key=lambda item: item["name"]) + )) + + hero_version_docs = list(get_hero_versions( + project_name, subset_ids=product_ids + )) + + version_ids = set() + version_docs_by_parent_id = {} + for version_doc in version_docs: + parent_id = version_doc["parent"] + if parent_id not in version_docs_by_parent_id: + version_ids.add(version_doc["_id"]) + version_docs_by_parent_id[parent_id] = version_doc + + hero_version_docs_by_parent_id = {} + for hero_version_doc in hero_version_docs: + version_ids.add(hero_version_doc["_id"]) + parent_id = hero_version_doc["parent"] + hero_version_docs_by_parent_id[parent_id] = hero_version_doc + + repre_docs = get_representations( + project_name, version_ids=version_ids + ) + repre_docs_by_parent_id_by_name = collections.defaultdict(dict) + for repre_doc in repre_docs: + parent_id = repre_doc["parent"] + name = repre_doc["name"] + repre_docs_by_parent_id_by_name[parent_id][name] = repre_doc + + for container in self._items: + self._switch_container( + container, + loader, + selected_folder_id, + selected_product_name, + selected_representation, + product_docs_by_parent_and_name, + version_docs_by_parent_id, + hero_version_docs_by_parent_id, + repre_docs_by_parent_id_by_name, + ) + + self.switched.emit() + + self.close() + + def _switch_container( + self, + container, + loader, + selected_folder_id, + product_name, + selected_representation, + product_docs_by_parent_and_name, + version_docs_by_parent_id, + hero_version_docs_by_parent_id, + repre_docs_by_parent_id_by_name, + ): + container_repre_id = container["representation"] + container_repre = self._repre_docs_by_id[container_repre_id] + container_repre_name = container_repre["name"] + container_version_id = container_repre["parent"] + + container_version = self._version_docs_by_id[container_version_id] + + container_product_id = container_version["parent"] + container_product = self._product_docs_by_id[container_product_id] + + if selected_folder_id: + folder_id = selected_folder_id + else: + folder_id = container_product["parent"] + + products_by_name = product_docs_by_parent_and_name[folder_id] + if product_name: + product_doc = products_by_name[product_name] + else: + product_doc = products_by_name[container_product["name"]] + + repre_doc = None + product_id = product_doc["_id"] + if container_version["type"] == "hero_version": + hero_version = hero_version_docs_by_parent_id.get( + product_id + ) + if hero_version: + _repres = repre_docs_by_parent_id_by_name.get( + hero_version["_id"] + ) + if selected_representation: + repre_doc = _repres.get(selected_representation) + else: + repre_doc = _repres.get(container_repre_name) + + if not repre_doc: + version_doc = version_docs_by_parent_id[product_id] + version_id = version_doc["_id"] + repres_by_name = repre_docs_by_parent_id_by_name[version_id] + if selected_representation: + repre_doc = repres_by_name[selected_representation] + else: + repre_doc = repres_by_name[container_repre_name] + + error = None + try: + switch_container(container, repre_doc, loader) + except ( + LoaderSwitchNotImplementedError, + IncompatibleLoaderError, + LoaderNotFoundError, + ) as exc: + error = str(exc) + except Exception: + error = ( + "Switch asset failed. " + "Search console log for more details." + ) + if error is not None: + log.warning(( + "Couldn't switch asset." + "See traceback for more information." + ), exc_info=True) + dialog = QtWidgets.QMessageBox(self) + dialog.setWindowTitle("Switch asset failed") + dialog.setText(error) + dialog.exec_() diff --git a/openpype/tools/ayon_sceneinventory/switch_dialog/folders_input.py b/openpype/tools/ayon_sceneinventory/switch_dialog/folders_input.py new file mode 100644 index 0000000000..699c62371a --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/switch_dialog/folders_input.py @@ -0,0 +1,307 @@ +from qtpy import QtWidgets, QtCore +import qtawesome + +from openpype.tools.utils import ( + PlaceholderLineEdit, + BaseClickableFrame, + set_style_property, +) +from openpype.tools.ayon_utils.widgets import FoldersWidget + +NOT_SET = object() + + +class ClickableLineEdit(QtWidgets.QLineEdit): + """QLineEdit capturing left mouse click. + + Triggers `clicked` signal on mouse click. + """ + clicked = QtCore.Signal() + + def __init__(self, *args, **kwargs): + super(ClickableLineEdit, self).__init__(*args, **kwargs) + self.setReadOnly(True) + self._mouse_pressed = False + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._mouse_pressed = True + event.accept() + + def mouseMoveEvent(self, event): + event.accept() + + def mouseReleaseEvent(self, event): + if self._mouse_pressed: + self._mouse_pressed = False + if self.rect().contains(event.pos()): + self.clicked.emit() + event.accept() + + def mouseDoubleClickEvent(self, event): + event.accept() + + +class ControllerWrap: + def __init__(self, controller): + self._controller = controller + self._selected_folder_id = None + + def emit_event(self, *args, **kwargs): + self._controller.emit_event(*args, **kwargs) + + def register_event_callback(self, *args, **kwargs): + self._controller.register_event_callback(*args, **kwargs) + + def get_current_project_name(self): + return self._controller.get_current_project_name() + + def get_folder_items(self, *args, **kwargs): + return self._controller.get_folder_items(*args, **kwargs) + + def set_selected_folder(self, folder_id): + self._selected_folder_id = folder_id + + def get_selected_folder_id(self): + return self._selected_folder_id + + +class FoldersDialog(QtWidgets.QDialog): + """Dialog to select asset for a context of instance.""" + + def __init__(self, controller, parent): + super(FoldersDialog, self).__init__(parent) + self.setWindowTitle("Select folder") + + filter_input = PlaceholderLineEdit(self) + filter_input.setPlaceholderText("Filter folders..") + + controller_wrap = ControllerWrap(controller) + folders_widget = FoldersWidget(controller_wrap, self) + folders_widget.set_deselectable(True) + + ok_btn = QtWidgets.QPushButton("OK", self) + cancel_btn = QtWidgets.QPushButton("Cancel", self) + + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn) + btns_layout.addWidget(cancel_btn) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(filter_input, 0) + layout.addWidget(folders_widget, 1) + layout.addLayout(btns_layout, 0) + + folders_widget.double_clicked.connect(self._on_ok_clicked) + folders_widget.refreshed.connect(self._on_folders_refresh) + filter_input.textChanged.connect(self._on_filter_change) + ok_btn.clicked.connect(self._on_ok_clicked) + cancel_btn.clicked.connect(self._on_cancel_clicked) + + self._filter_input = filter_input + self._ok_btn = ok_btn + self._cancel_btn = cancel_btn + + self._folders_widget = folders_widget + self._controller_wrap = controller_wrap + + # Set selected folder only when user confirms the dialog + self._selected_folder_id = None + self._selected_folder_label = None + + self._folder_id_to_select = NOT_SET + + self._first_show = True + self._default_height = 500 + + def showEvent(self, event): + """Refresh asset model on show.""" + super(FoldersDialog, self).showEvent(event) + if self._first_show: + self._first_show = False + self._on_first_show() + + def refresh(self): + project_name = self._controller_wrap.get_current_project_name() + self._folders_widget.set_project_name(project_name) + + def _on_first_show(self): + center = self.rect().center() + size = self.size() + size.setHeight(self._default_height) + + self.resize(size) + new_pos = self.mapToGlobal(center) + new_pos.setX(new_pos.x() - int(self.width() / 2)) + new_pos.setY(new_pos.y() - int(self.height() / 2)) + self.move(new_pos) + + def _on_folders_refresh(self): + if self._folder_id_to_select is NOT_SET: + return + self._folders_widget.set_selected_folder(self._folder_id_to_select) + self._folder_id_to_select = NOT_SET + + def _on_filter_change(self, text): + """Trigger change of filter of folders.""" + + self._folders_widget.set_name_filter(text) + + def _on_cancel_clicked(self): + self.done(0) + + def _on_ok_clicked(self): + self._selected_folder_id = ( + self._folders_widget.get_selected_folder_id() + ) + self._selected_folder_label = ( + self._folders_widget.get_selected_folder_label() + ) + self.done(1) + + def set_selected_folder(self, folder_id): + """Change preselected folder before showing the dialog. + + This also resets model and clean filter. + """ + + if ( + self._folders_widget.is_refreshing + or self._folders_widget.get_project_name() is None + ): + self._folder_id_to_select = folder_id + else: + self._folders_widget.set_selected_folder(folder_id) + + def get_selected_folder_id(self): + """Get selected folder id. + + Returns: + Union[str, None]: Selected folder id or None if nothing + is selected. + """ + return self._selected_folder_id + + def get_selected_folder_label(self): + return self._selected_folder_label + + +class FoldersField(BaseClickableFrame): + """Field where asset name of selected instance/s is showed. + + Click on the field will trigger `FoldersDialog`. + """ + value_changed = QtCore.Signal() + + def __init__(self, controller, parent): + super(FoldersField, self).__init__(parent) + self.setObjectName("AssetNameInputWidget") + + # Don't use 'self' for parent! + # - this widget has specific styles + dialog = FoldersDialog(controller, parent) + + name_input = ClickableLineEdit(self) + name_input.setObjectName("AssetNameInput") + + icon = qtawesome.icon("fa.window-maximize", color="white") + icon_btn = QtWidgets.QPushButton(self) + icon_btn.setIcon(icon) + icon_btn.setObjectName("AssetNameInputButton") + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(name_input, 1) + layout.addWidget(icon_btn, 0) + + # Make sure all widgets are vertically extended to highest widget + for widget in ( + name_input, + icon_btn + ): + w_size_policy = widget.sizePolicy() + w_size_policy.setVerticalPolicy( + QtWidgets.QSizePolicy.MinimumExpanding) + widget.setSizePolicy(w_size_policy) + + size_policy = self.sizePolicy() + size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Maximum) + self.setSizePolicy(size_policy) + + name_input.clicked.connect(self._mouse_release_callback) + icon_btn.clicked.connect(self._mouse_release_callback) + dialog.finished.connect(self._on_dialog_finish) + + self._controller = controller + self._dialog = dialog + self._name_input = name_input + self._icon_btn = icon_btn + + self._selected_folder_id = None + self._selected_folder_label = None + self._selected_items = [] + self._is_valid = True + + def refresh(self): + self._dialog.refresh() + + def is_valid(self): + """Is asset valid.""" + return self._is_valid + + def get_selected_folder_id(self): + """Selected asset names.""" + return self._selected_folder_id + + def get_selected_folder_label(self): + return self._selected_folder_label + + def set_text(self, text): + """Set text in text field. + + Does not change selected items (assets). + """ + self._name_input.setText(text) + + def set_valid(self, is_valid): + state = "" + if not is_valid: + state = "invalid" + self._set_state_property(state) + + def set_selected_item(self, folder_id=None, folder_label=None): + """Set folder for selection. + + Args: + folder_id (Optional[str]): Folder id to select. + folder_label (Optional[str]): Folder label. + """ + + self._selected_folder_id = folder_id + if not folder_id: + folder_label = None + elif folder_id and not folder_label: + folder_label = self._controller.get_folder_label(folder_id) + self._selected_folder_label = folder_label + self.set_text(folder_label if folder_label else "") + + def _on_dialog_finish(self, result): + if not result: + return + + folder_id = self._dialog.get_selected_folder_id() + folder_label = self._dialog.get_selected_folder_label() + self.set_selected_item(folder_id, folder_label) + + self.value_changed.emit() + + def _mouse_release_callback(self): + self._dialog.set_selected_folder(self._selected_folder_id) + self._dialog.open() + + def _set_state_property(self, state): + set_style_property(self, "state", state) + set_style_property(self._name_input, "state", state) + set_style_property(self._icon_btn, "state", state) diff --git a/openpype/tools/ayon_sceneinventory/switch_dialog/widgets.py b/openpype/tools/ayon_sceneinventory/switch_dialog/widgets.py new file mode 100644 index 0000000000..50a49e0ce1 --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/switch_dialog/widgets.py @@ -0,0 +1,94 @@ +from qtpy import QtWidgets, QtCore + +from openpype import style + + +class ButtonWithMenu(QtWidgets.QToolButton): + def __init__(self, parent=None): + super(ButtonWithMenu, self).__init__(parent) + + self.setObjectName("ButtonWithMenu") + + self.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup) + menu = QtWidgets.QMenu(self) + + self.setMenu(menu) + + self._menu = menu + self._actions = [] + + def menu(self): + return self._menu + + def clear_actions(self): + if self._menu is not None: + self._menu.clear() + self._actions = [] + + def add_action(self, action): + self._actions.append(action) + self._menu.addAction(action) + + def _on_action_trigger(self): + action = self.sender() + if action not in self._actions: + return + action.trigger() + + +class SearchComboBox(QtWidgets.QComboBox): + """Searchable ComboBox with empty placeholder value as first value""" + + def __init__(self, parent): + super(SearchComboBox, self).__init__(parent) + + self.setEditable(True) + self.setInsertPolicy(QtWidgets.QComboBox.NoInsert) + + combobox_delegate = QtWidgets.QStyledItemDelegate(self) + self.setItemDelegate(combobox_delegate) + + completer = self.completer() + completer.setCompletionMode( + QtWidgets.QCompleter.PopupCompletion + ) + completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + + completer_view = completer.popup() + completer_view.setObjectName("CompleterView") + completer_delegate = QtWidgets.QStyledItemDelegate(completer_view) + completer_view.setItemDelegate(completer_delegate) + completer_view.setStyleSheet(style.load_stylesheet()) + + self._combobox_delegate = combobox_delegate + + self._completer_delegate = completer_delegate + self._completer = completer + + def set_placeholder(self, placeholder): + self.lineEdit().setPlaceholderText(placeholder) + + def populate(self, items): + self.clear() + self.addItems([""]) # ensure first item is placeholder + self.addItems(items) + + def get_valid_value(self): + """Return the current text if it's a valid value else None + + Note: The empty placeholder value is valid and returns as "" + + """ + + text = self.currentText() + lookup = set(self.itemText(i) for i in range(self.count())) + if text not in lookup: + return None + + return text or None + + def set_valid_value(self, value): + """Try to locate 'value' and pre-select it in dropdown.""" + index = self.findText(value) + if index > -1: + self.setCurrentIndex(index) diff --git a/openpype/tools/ayon_sceneinventory/view.py b/openpype/tools/ayon_sceneinventory/view.py new file mode 100644 index 0000000000..039b498b1b --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/view.py @@ -0,0 +1,825 @@ +import uuid +import collections +import logging +import itertools +from functools import partial + +from qtpy import QtWidgets, QtCore +import qtawesome + +from openpype.client import ( + get_version_by_id, + get_versions, + get_hero_versions, + get_representation_by_id, + get_representations, +) +from openpype import style +from openpype.pipeline import ( + HeroVersionType, + update_container, + remove_container, + discover_inventory_actions, +) +from openpype.tools.utils.lib import ( + iter_model_rows, + format_version +) + +from .switch_dialog import SwitchAssetDialog +from .model import InventoryModel + + +DEFAULT_COLOR = "#fb9c15" + +log = logging.getLogger("SceneInventory") + + +class SceneInventoryView(QtWidgets.QTreeView): + data_changed = QtCore.Signal() + hierarchy_view_changed = QtCore.Signal(bool) + + def __init__(self, controller, parent): + super(SceneInventoryView, self).__init__(parent=parent) + + # view settings + self.setIndentation(12) + self.setAlternatingRowColors(True) + self.setSortingEnabled(True) + self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + self.customContextMenuRequested.connect(self._show_right_mouse_menu) + + self._hierarchy_view = False + self._selected = None + + self._controller = controller + + def _set_hierarchy_view(self, enabled): + if enabled == self._hierarchy_view: + return + self._hierarchy_view = enabled + self.hierarchy_view_changed.emit(enabled) + + def _enter_hierarchy(self, items): + self._selected = set(i["objectName"] for i in items) + self._set_hierarchy_view(True) + self.data_changed.emit() + self.expandToDepth(1) + self.setStyleSheet(""" + QTreeView { + border-color: #fb9c15; + } + """) + + def _leave_hierarchy(self): + self._set_hierarchy_view(False) + self.data_changed.emit() + self.setStyleSheet("QTreeView {}") + + def _build_item_menu_for_selection(self, items, menu): + # Exclude items that are "NOT FOUND" since setting versions, updating + # and removal won't work for those items. + items = [item for item in items if not item.get("isNotFound")] + if not items: + return + + # An item might not have a representation, for example when an item + # is listed as "NOT FOUND" + repre_ids = set() + for item in items: + repre_id = item["representation"] + try: + uuid.UUID(repre_id) + repre_ids.add(repre_id) + except ValueError: + pass + + project_name = self._controller.get_current_project_name() + repre_docs = get_representations( + project_name, representation_ids=repre_ids, fields=["parent"] + ) + + version_ids = { + repre_doc["parent"] + for repre_doc in repre_docs + } + + loaded_versions = get_versions( + project_name, version_ids=version_ids, hero=True + ) + + loaded_hero_versions = [] + versions_by_parent_id = collections.defaultdict(list) + subset_ids = set() + for version in loaded_versions: + if version["type"] == "hero_version": + loaded_hero_versions.append(version) + else: + parent_id = version["parent"] + versions_by_parent_id[parent_id].append(version) + subset_ids.add(parent_id) + + all_versions = get_versions( + project_name, subset_ids=subset_ids, hero=True + ) + hero_versions = [] + versions = [] + for version in all_versions: + if version["type"] == "hero_version": + hero_versions.append(version) + else: + versions.append(version) + + has_loaded_hero_versions = len(loaded_hero_versions) > 0 + has_available_hero_version = len(hero_versions) > 0 + has_outdated = False + + for version in versions: + parent_id = version["parent"] + current_versions = versions_by_parent_id[parent_id] + for current_version in current_versions: + if current_version["name"] < version["name"]: + has_outdated = True + break + + if has_outdated: + break + + switch_to_versioned = None + if has_loaded_hero_versions: + def _on_switch_to_versioned(items): + repre_ids = { + item["representation"] + for item in items + } + + repre_docs = get_representations( + project_name, + representation_ids=repre_ids, + fields=["parent"] + ) + + version_ids = set() + version_id_by_repre_id = {} + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + repre_id = str(repre_doc["_id"]) + version_id_by_repre_id[repre_id] = version_id + version_ids.add(version_id) + + hero_versions = get_hero_versions( + project_name, + version_ids=version_ids, + fields=["version_id"] + ) + + hero_src_version_ids = set() + for hero_version in hero_versions: + version_id = hero_version["version_id"] + hero_src_version_ids.add(version_id) + hero_version_id = hero_version["_id"] + for _repre_id, current_version_id in ( + version_id_by_repre_id.items() + ): + if current_version_id == hero_version_id: + version_id_by_repre_id[_repre_id] = version_id + + version_docs = get_versions( + project_name, + version_ids=hero_src_version_ids, + fields=["name"] + ) + version_name_by_id = {} + for version_doc in version_docs: + version_name_by_id[version_doc["_id"]] = \ + version_doc["name"] + + # Specify version per item to update to + update_items = [] + update_versions = [] + for item in items: + repre_id = item["representation"] + version_id = version_id_by_repre_id.get(repre_id) + version_name = version_name_by_id.get(version_id) + if version_name is not None: + update_items.append(item) + update_versions.append(version_name) + self._update_containers(update_items, update_versions) + + update_icon = qtawesome.icon( + "fa.asterisk", + color=DEFAULT_COLOR + ) + switch_to_versioned = QtWidgets.QAction( + update_icon, + "Switch to versioned", + menu + ) + switch_to_versioned.triggered.connect( + lambda: _on_switch_to_versioned(items) + ) + + update_to_latest_action = None + if has_outdated or has_loaded_hero_versions: + update_icon = qtawesome.icon( + "fa.angle-double-up", + color=DEFAULT_COLOR + ) + update_to_latest_action = QtWidgets.QAction( + update_icon, + "Update to latest", + menu + ) + update_to_latest_action.triggered.connect( + lambda: self._update_containers(items, version=-1) + ) + + change_to_hero = None + if has_available_hero_version: + # TODO change icon + change_icon = qtawesome.icon( + "fa.asterisk", + color="#00b359" + ) + change_to_hero = QtWidgets.QAction( + change_icon, + "Change to hero", + menu + ) + change_to_hero.triggered.connect( + lambda: self._update_containers(items, + version=HeroVersionType(-1)) + ) + + # set version + set_version_icon = qtawesome.icon("fa.hashtag", color=DEFAULT_COLOR) + set_version_action = QtWidgets.QAction( + set_version_icon, + "Set version", + menu + ) + set_version_action.triggered.connect( + lambda: self._show_version_dialog(items)) + + # switch folder + switch_folder_icon = qtawesome.icon("fa.sitemap", color=DEFAULT_COLOR) + switch_folder_action = QtWidgets.QAction( + switch_folder_icon, + "Switch Folder", + menu + ) + switch_folder_action.triggered.connect( + lambda: self._show_switch_dialog(items)) + + # remove + remove_icon = qtawesome.icon("fa.remove", color=DEFAULT_COLOR) + remove_action = QtWidgets.QAction(remove_icon, "Remove items", menu) + remove_action.triggered.connect( + lambda: self._show_remove_warning_dialog(items)) + + # add the actions + if switch_to_versioned: + menu.addAction(switch_to_versioned) + + if update_to_latest_action: + menu.addAction(update_to_latest_action) + + if change_to_hero: + menu.addAction(change_to_hero) + + menu.addAction(set_version_action) + menu.addAction(switch_folder_action) + + menu.addSeparator() + + menu.addAction(remove_action) + + self._handle_sync_server(menu, repre_ids) + + def _handle_sync_server(self, menu, repre_ids): + """Adds actions for download/upload when SyncServer is enabled + + Args: + menu (OptionMenu) + repre_ids (list) of object_ids + + Returns: + (OptionMenu) + """ + + if not self._controller.is_sync_server_enabled(): + return + + menu.addSeparator() + + download_icon = qtawesome.icon("fa.download", color=DEFAULT_COLOR) + download_active_action = QtWidgets.QAction( + download_icon, + "Download", + menu + ) + download_active_action.triggered.connect( + lambda: self._add_sites(repre_ids, "active_site")) + + upload_icon = qtawesome.icon("fa.upload", color=DEFAULT_COLOR) + upload_remote_action = QtWidgets.QAction( + upload_icon, + "Upload", + menu + ) + upload_remote_action.triggered.connect( + lambda: self._add_sites(repre_ids, "remote_site")) + + menu.addAction(download_active_action) + menu.addAction(upload_remote_action) + + def _add_sites(self, repre_ids, site_type): + """(Re)sync all 'repre_ids' to specific site. + + It checks if opposite site has fully available content to limit + accidents. (ReSync active when no remote >> losing active content) + + Args: + repre_ids (list) + site_type (Literal[active_site, remote_site]): Site type. + """ + + self._controller.resync_representations(repre_ids, site_type) + + self.data_changed.emit() + + def _build_item_menu(self, items=None): + """Create menu for the selected items""" + + if not items: + items = [] + + menu = QtWidgets.QMenu(self) + + # add the actions + self._build_item_menu_for_selection(items, menu) + + # These two actions should be able to work without selection + # expand all items + expandall_action = QtWidgets.QAction(menu, text="Expand all items") + expandall_action.triggered.connect(self.expandAll) + + # collapse all items + collapse_action = QtWidgets.QAction(menu, text="Collapse all items") + collapse_action.triggered.connect(self.collapseAll) + + menu.addAction(expandall_action) + menu.addAction(collapse_action) + + custom_actions = self._get_custom_actions(containers=items) + if custom_actions: + submenu = QtWidgets.QMenu("Actions", self) + for action in custom_actions: + color = action.color or DEFAULT_COLOR + icon = qtawesome.icon("fa.%s" % action.icon, color=color) + action_item = QtWidgets.QAction(icon, action.label, submenu) + action_item.triggered.connect( + partial(self._process_custom_action, action, items)) + + submenu.addAction(action_item) + + menu.addMenu(submenu) + + # go back to flat view + back_to_flat_action = None + if self._hierarchy_view: + back_to_flat_icon = qtawesome.icon("fa.list", color=DEFAULT_COLOR) + back_to_flat_action = QtWidgets.QAction( + back_to_flat_icon, + "Back to Full-View", + menu + ) + back_to_flat_action.triggered.connect(self._leave_hierarchy) + + # send items to hierarchy view + enter_hierarchy_icon = qtawesome.icon("fa.indent", color="#d8d8d8") + enter_hierarchy_action = QtWidgets.QAction( + enter_hierarchy_icon, + "Cherry-Pick (Hierarchy)", + menu + ) + enter_hierarchy_action.triggered.connect( + lambda: self._enter_hierarchy(items)) + + if items: + menu.addAction(enter_hierarchy_action) + + if back_to_flat_action is not None: + menu.addAction(back_to_flat_action) + + return menu + + def _get_custom_actions(self, containers): + """Get the registered Inventory Actions + + Args: + containers(list): collection of containers + + Returns: + list: collection of filter and initialized actions + """ + + def sorter(Plugin): + """Sort based on order attribute of the plugin""" + return Plugin.order + + # Fedd an empty dict if no selection, this will ensure the compat + # lookup always work, so plugin can interact with Scene Inventory + # reversely. + containers = containers or [dict()] + + # Check which action will be available in the menu + Plugins = discover_inventory_actions() + compatible = [p() for p in Plugins if + any(p.is_compatible(c) for c in containers)] + + return sorted(compatible, key=sorter) + + def _process_custom_action(self, action, containers): + """Run action and if results are returned positive update the view + + If the result is list or dict, will select view items by the result. + + Args: + action (InventoryAction): Inventory Action instance + containers (list): Data of currently selected items + + Returns: + None + """ + + result = action.process(containers) + if result: + self.data_changed.emit() + + if isinstance(result, (list, set)): + self._select_items_by_action(result) + + if isinstance(result, dict): + self._select_items_by_action( + result["objectNames"], result["options"] + ) + + def _select_items_by_action(self, object_names, options=None): + """Select view items by the result of action + + Args: + object_names (list or set): A list/set of container object name + options (dict): GUI operation options. + + Returns: + None + + """ + options = options or dict() + + if options.get("clear", True): + self.clearSelection() + + object_names = set(object_names) + if ( + self._hierarchy_view + and not self._selected.issuperset(object_names) + ): + # If any container not in current cherry-picked view, update + # view before selecting them. + self._selected.update(object_names) + self.data_changed.emit() + + model = self.model() + selection_model = self.selectionModel() + + select_mode = { + "select": QtCore.QItemSelectionModel.Select, + "deselect": QtCore.QItemSelectionModel.Deselect, + "toggle": QtCore.QItemSelectionModel.Toggle, + }[options.get("mode", "select")] + + for index in iter_model_rows(model, 0): + item = index.data(InventoryModel.ItemRole) + if item.get("isGroupNode"): + continue + + name = item.get("objectName") + if name in object_names: + self.scrollTo(index) # Ensure item is visible + flags = select_mode | QtCore.QItemSelectionModel.Rows + selection_model.select(index, flags) + + object_names.remove(name) + + if len(object_names) == 0: + break + + def _show_right_mouse_menu(self, pos): + """Display the menu when at the position of the item clicked""" + + globalpos = self.viewport().mapToGlobal(pos) + + if not self.selectionModel().hasSelection(): + print("No selection") + # Build menu without selection, feed an empty list + menu = self._build_item_menu() + menu.exec_(globalpos) + return + + active = self.currentIndex() # index under mouse + active = active.sibling(active.row(), 0) # get first column + + # move index under mouse + indices = self.get_indices() + if active in indices: + indices.remove(active) + + indices.append(active) + + # Extend to the sub-items + all_indices = self._extend_to_children(indices) + items = [dict(i.data(InventoryModel.ItemRole)) for i in all_indices + if i.parent().isValid()] + + if self._hierarchy_view: + # Ensure no group item + items = [n for n in items if not n.get("isGroupNode")] + + menu = self._build_item_menu(items) + menu.exec_(globalpos) + + def get_indices(self): + """Get the selected rows""" + selection_model = self.selectionModel() + return selection_model.selectedRows() + + def _extend_to_children(self, indices): + """Extend the indices to the children indices. + + Top-level indices are extended to its children indices. Sub-items + are kept as is. + + Args: + indices (list): The indices to extend. + + Returns: + list: The children indices + + """ + def get_children(i): + model = i.model() + rows = model.rowCount(parent=i) + for row in range(rows): + child = model.index(row, 0, parent=i) + yield child + + subitems = set() + for i in indices: + valid_parent = i.parent().isValid() + if valid_parent and i not in subitems: + subitems.add(i) + + if self._hierarchy_view: + # Assume this is a group item + for child in get_children(i): + subitems.add(child) + else: + # is top level item + for child in get_children(i): + subitems.add(child) + + return list(subitems) + + def _show_version_dialog(self, items): + """Create a dialog with the available versions for the selected file + + Args: + items (list): list of items to run the "set_version" for + + Returns: + None + """ + + active = items[-1] + + project_name = self._controller.get_current_project_name() + # Get available versions for active representation + repre_doc = get_representation_by_id( + project_name, + active["representation"], + fields=["parent"] + ) + + repre_version_doc = get_version_by_id( + project_name, + repre_doc["parent"], + fields=["parent"] + ) + + version_docs = list(get_versions( + project_name, + subset_ids=[repre_version_doc["parent"]], + hero=True + )) + hero_version = None + standard_versions = [] + for version_doc in version_docs: + if version_doc["type"] == "hero_version": + hero_version = version_doc + else: + standard_versions.append(version_doc) + versions = list(reversed( + sorted(standard_versions, key=lambda item: item["name"]) + )) + if hero_version: + _version_id = hero_version["version_id"] + for _version in versions: + if _version["_id"] != _version_id: + continue + + hero_version["name"] = HeroVersionType( + _version["name"] + ) + hero_version["data"] = _version["data"] + break + + # Get index among the listed versions + current_item = None + current_version = active["version"] + if isinstance(current_version, HeroVersionType): + current_item = hero_version + else: + for version in versions: + if version["name"] == current_version: + current_item = version + break + + all_versions = [] + if hero_version: + all_versions.append(hero_version) + all_versions.extend(versions) + + if current_item: + index = all_versions.index(current_item) + else: + index = 0 + + versions_by_label = dict() + labels = [] + for version in all_versions: + is_hero = version["type"] == "hero_version" + label = format_version(version["name"], is_hero) + labels.append(label) + versions_by_label[label] = version["name"] + + label, state = QtWidgets.QInputDialog.getItem( + self, + "Set version..", + "Set version number to", + labels, + current=index, + editable=False + ) + if not state: + return + + if label: + version = versions_by_label[label] + self._update_containers(items, version) + + def _show_switch_dialog(self, items): + """Display Switch dialog""" + dialog = SwitchAssetDialog(self._controller, self, items) + dialog.switched.connect(self.data_changed.emit) + dialog.show() + + def _show_remove_warning_dialog(self, items): + """Prompt a dialog to inform the user the action will remove items""" + + accept = QtWidgets.QMessageBox.Ok + buttons = accept | QtWidgets.QMessageBox.Cancel + + state = QtWidgets.QMessageBox.question( + self, + "Are you sure?", + "Are you sure you want to remove {} item(s)".format(len(items)), + buttons=buttons, + defaultButton=accept + ) + + if state != accept: + return + + for item in items: + remove_container(item) + self.data_changed.emit() + + def _show_version_error_dialog(self, version, items): + """Shows QMessageBox when version switch doesn't work + + Args: + version: str or int or None + """ + if version == -1: + version_str = "latest" + elif isinstance(version, HeroVersionType): + version_str = "hero" + elif isinstance(version, int): + version_str = "v{:03d}".format(version) + else: + version_str = version + + dialog = QtWidgets.QMessageBox(self) + dialog.setIcon(QtWidgets.QMessageBox.Warning) + dialog.setStyleSheet(style.load_stylesheet()) + dialog.setWindowTitle("Update failed") + + switch_btn = dialog.addButton( + "Switch Folder", + QtWidgets.QMessageBox.ActionRole + ) + switch_btn.clicked.connect(lambda: self._show_switch_dialog(items)) + + dialog.addButton(QtWidgets.QMessageBox.Cancel) + + msg = ( + "Version update to '{}' failed as representation doesn't exist." + "\n\nPlease update to version with a valid representation" + " OR \n use 'Switch Folder' button to change folder." + ).format(version_str) + dialog.setText(msg) + dialog.exec_() + + def update_all(self): + """Update all items that are currently 'outdated' in the view""" + # Get the source model through the proxy model + model = self.model().sourceModel() + + # Get all items from outdated groups + outdated_items = [] + for index in iter_model_rows(model, + column=0, + include_root=False): + item = index.data(model.ItemRole) + + if not item.get("isGroupNode"): + continue + + # Only the group nodes contain the "highest_version" data and as + # such we find only the groups and take its children. + if not model.outdated(item): + continue + + # Collect all children which we want to update + children = item.children() + outdated_items.extend(children) + + if not outdated_items: + log.info("Nothing to update.") + return + + # Trigger update to latest + self._update_containers(outdated_items, version=-1) + + def _update_containers(self, items, version): + """Helper to update items to given version (or version per item) + + If at least one item is specified this will always try to refresh + the inventory even if errors occurred on any of the items. + + Arguments: + items (list): Items to update + version (int or list): Version to set to. + This can be a list specifying a version for each item. + Like `update_container` version -1 sets the latest version + and HeroTypeVersion instances set the hero version. + + """ + + if isinstance(version, (list, tuple)): + # We allow a unique version to be specified per item. In that case + # the length must match with the items + assert len(items) == len(version), ( + "Number of items mismatches number of versions: " + "{} items - {} versions".format(len(items), len(version)) + ) + versions = version + else: + # Repeat the same version infinitely + versions = itertools.repeat(version) + + # Trigger update to latest + try: + for item, item_version in zip(items, versions): + try: + update_container(item, item_version) + except AssertionError: + self._show_version_error_dialog(item_version, [item]) + log.warning("Update failed", exc_info=True) + finally: + # Always update the scene inventory view, even if errors occurred + self.data_changed.emit() diff --git a/openpype/tools/ayon_sceneinventory/window.py b/openpype/tools/ayon_sceneinventory/window.py new file mode 100644 index 0000000000..427bf4c50d --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/window.py @@ -0,0 +1,200 @@ +from qtpy import QtWidgets, QtCore, QtGui +import qtawesome + +from openpype import style, resources +from openpype.tools.utils.delegates import VersionDelegate +from openpype.tools.utils.lib import ( + preserve_expanded_rows, + preserve_selection, +) +from openpype.tools.ayon_sceneinventory import SceneInventoryController + +from .model import ( + InventoryModel, + FilterProxyModel +) +from .view import SceneInventoryView + + +class ControllerVersionDelegate(VersionDelegate): + """Version delegate that uses controller to get project. + + Original VersionDelegate is using 'AvalonMongoDB' object instead. Don't + worry about the variable name, object is stored to '_dbcon' attribute. + """ + + def get_project_name(self): + self._dbcon.get_current_project_name() + + +class SceneInventoryWindow(QtWidgets.QDialog): + """Scene Inventory window""" + + def __init__(self, controller=None, parent=None): + super(SceneInventoryWindow, self).__init__(parent) + + if controller is None: + controller = SceneInventoryController() + + project_name = controller.get_current_project_name() + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + self.setWindowIcon(icon) + self.setWindowTitle("Scene Inventory - {}".format(project_name)) + self.setObjectName("SceneInventory") + + self.resize(1100, 480) + + # region control + + filter_label = QtWidgets.QLabel("Search", self) + text_filter = QtWidgets.QLineEdit(self) + + outdated_only_checkbox = QtWidgets.QCheckBox( + "Filter to outdated", self + ) + outdated_only_checkbox.setToolTip("Show outdated files only") + outdated_only_checkbox.setChecked(False) + + icon = qtawesome.icon("fa.arrow-up", color="white") + update_all_button = QtWidgets.QPushButton(self) + update_all_button.setToolTip("Update all outdated to latest version") + update_all_button.setIcon(icon) + + icon = qtawesome.icon("fa.refresh", color="white") + refresh_button = QtWidgets.QPushButton(self) + refresh_button.setToolTip("Refresh") + refresh_button.setIcon(icon) + + control_layout = QtWidgets.QHBoxLayout() + control_layout.addWidget(filter_label) + control_layout.addWidget(text_filter) + control_layout.addWidget(outdated_only_checkbox) + control_layout.addWidget(update_all_button) + control_layout.addWidget(refresh_button) + + model = InventoryModel(controller) + proxy = FilterProxyModel() + proxy.setSourceModel(model) + proxy.setDynamicSortFilter(True) + proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + + view = SceneInventoryView(controller, self) + view.setModel(proxy) + + sync_enabled = controller.is_sync_server_enabled() + view.setColumnHidden(model.active_site_col, not sync_enabled) + view.setColumnHidden(model.remote_site_col, not sync_enabled) + + # set some nice default widths for the view + view.setColumnWidth(0, 250) # name + view.setColumnWidth(1, 55) # version + view.setColumnWidth(2, 55) # count + view.setColumnWidth(3, 150) # family + view.setColumnWidth(4, 120) # group + view.setColumnWidth(5, 150) # loader + + # apply delegates + version_delegate = ControllerVersionDelegate(controller, self) + column = model.Columns.index("version") + view.setItemDelegateForColumn(column, version_delegate) + + layout = QtWidgets.QVBoxLayout(self) + layout.addLayout(control_layout) + layout.addWidget(view) + + show_timer = QtCore.QTimer() + show_timer.setInterval(0) + show_timer.setSingleShot(False) + + # signals + show_timer.timeout.connect(self._on_show_timer) + text_filter.textChanged.connect(self._on_text_filter_change) + outdated_only_checkbox.stateChanged.connect( + self._on_outdated_state_change + ) + view.hierarchy_view_changed.connect( + self._on_hierarchy_view_change + ) + view.data_changed.connect(self._on_refresh_request) + refresh_button.clicked.connect(self._on_refresh_request) + update_all_button.clicked.connect(self._on_update_all) + + self._show_timer = show_timer + self._show_counter = 0 + self._controller = controller + self._update_all_button = update_all_button + self._outdated_only_checkbox = outdated_only_checkbox + self._view = view + self._model = model + self._proxy = proxy + self._version_delegate = version_delegate + + self._first_show = True + self._first_refresh = True + + def showEvent(self, event): + super(SceneInventoryWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self.setStyleSheet(style.load_stylesheet()) + + self._show_counter = 0 + self._show_timer.start() + + def keyPressEvent(self, event): + """Custom keyPressEvent. + + 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 accidentally perform Maya commands + whilst trying to name an instance. + + """ + + def _on_refresh_request(self): + """Signal callback to trigger 'refresh' without any arguments.""" + + self.refresh() + + def refresh(self, containers=None): + self._first_refresh = False + self._controller.reset() + with preserve_expanded_rows( + tree_view=self._view, + role=self._model.UniqueRole + ): + with preserve_selection( + tree_view=self._view, + role=self._model.UniqueRole, + current_index=False + ): + kwargs = {"containers": containers} + # TODO do not touch view's inner attribute + if self._view._hierarchy_view: + kwargs["selected"] = self._view._selected + self._model.refresh(**kwargs) + + def _on_show_timer(self): + if self._show_counter < 3: + self._show_counter += 1 + return + self._show_timer.stop() + self.refresh() + + def _on_hierarchy_view_change(self, enabled): + self._proxy.set_hierarchy_view(enabled) + self._model.set_hierarchy_view(enabled) + + def _on_text_filter_change(self, text_filter): + if hasattr(self._proxy, "setFilterRegExp"): + self._proxy.setFilterRegExp(text_filter) + else: + self._proxy.setFilterRegularExpression(text_filter) + + def _on_outdated_state_change(self): + self._proxy.set_filter_outdated( + self._outdated_only_checkbox.isChecked() + ) + + def _on_update_all(self): + self._view.update_all() diff --git a/openpype/tools/ayon_utils/models/__init__.py b/openpype/tools/ayon_utils/models/__init__.py index 1434282c5b..69722b5e21 100644 --- a/openpype/tools/ayon_utils/models/__init__.py +++ b/openpype/tools/ayon_utils/models/__init__.py @@ -12,6 +12,7 @@ from .hierarchy import ( HierarchyModel, HIERARCHY_MODEL_SENDER, ) +from .thumbnails import ThumbnailsModel __all__ = ( @@ -26,4 +27,6 @@ __all__ = ( "TaskItem", "HierarchyModel", "HIERARCHY_MODEL_SENDER", + + "ThumbnailsModel", ) diff --git a/openpype/tools/ayon_utils/models/hierarchy.py b/openpype/tools/ayon_utils/models/hierarchy.py index 8e01c557c5..fc6b8e1eb7 100644 --- a/openpype/tools/ayon_utils/models/hierarchy.py +++ b/openpype/tools/ayon_utils/models/hierarchy.py @@ -29,17 +29,21 @@ class FolderItem: parent_id (Union[str, None]): Parent folder id. If 'None' then project is parent. name (str): Name of folder. - label (str): Folder label. - icon_name (str): Name of icon from font awesome. - icon_color (str): Hex color string that will be used for icon. + path (str): Folder path. + folder_type (str): Type of folder. + label (Union[str, None]): Folder label. + icon (Union[dict[str, Any], None]): Icon definition. """ def __init__( - self, entity_id, parent_id, name, label, icon + self, entity_id, parent_id, name, path, folder_type, label, icon ): self.entity_id = entity_id self.parent_id = parent_id self.name = name + self.path = path + self.folder_type = folder_type + self.label = label or name if not icon: icon = { "type": "awesome-font", @@ -47,7 +51,6 @@ class FolderItem: "color": get_default_entity_icon_color() } self.icon = icon - self.label = label or name def to_data(self): """Converts folder item to data. @@ -60,6 +63,8 @@ class FolderItem: "entity_id": self.entity_id, "parent_id": self.parent_id, "name": self.name, + "path": self.path, + "folder_type": self.folder_type, "label": self.label, "icon": self.icon, } @@ -91,8 +96,7 @@ class TaskItem: name (str): Name of task. task_type (str): Type of task. parent_id (str): Parent folder id. - icon_name (str): Name of icon from font awesome. - icon_color (str): Hex color string that will be used for icon. + icon (Union[dict[str, Any], None]): Icon definitions. """ def __init__( @@ -184,12 +188,31 @@ def _get_task_items_from_tasks(tasks): def _get_folder_item_from_hierarchy_item(item): + name = item["name"] + path_parts = list(item["parents"]) + path_parts.append(name) + return FolderItem( item["id"], item["parentId"], - item["name"], + name, + "/".join(path_parts), + item["folderType"], item["label"], - None + None, + ) + + +def _get_folder_item_from_entity(entity): + name = entity["name"] + return FolderItem( + entity["id"], + entity["parentId"], + name, + entity["path"], + entity["folderType"], + entity["label"] or name, + None, ) @@ -199,13 +222,18 @@ class HierarchyModel(object): Hierarchy items are folders and tasks. Folders can have as parent another folder or project. Tasks can have as parent only folder. """ + lifetime = 60 # A minute def __init__(self, controller): - self._folders_items = NestedCacheItem(levels=1, default_factory=dict) - self._folders_by_id = NestedCacheItem(levels=2, default_factory=dict) + self._folders_items = NestedCacheItem( + levels=1, default_factory=dict, lifetime=self.lifetime) + self._folders_by_id = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) - self._task_items = NestedCacheItem(levels=2, default_factory=dict) - self._tasks_by_id = NestedCacheItem(levels=2, default_factory=dict) + self._task_items = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) + self._tasks_by_id = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) self._folders_refreshing = set() self._tasks_refreshing = set() @@ -219,13 +247,84 @@ class HierarchyModel(object): self._tasks_by_id.reset() def refresh_project(self, project_name): + """Force to refresh folder items for a project. + + Args: + project_name (str): Name of project to refresh. + """ + self._refresh_folders_cache(project_name) def get_folder_items(self, project_name, sender): + """Get folder items by project name. + + The folders are cached per project name. If the cache is not valid + then the folders are queried from server. + + Args: + project_name (str): Name of project where to look for folders. + sender (Union[str, None]): Who requested the folder ids. + + Returns: + dict[str, FolderItem]: Folder items by id. + """ + if not self._folders_items[project_name].is_valid: self._refresh_folders_cache(project_name, sender) return self._folders_items[project_name].get_data() + def get_folder_items_by_id(self, project_name, folder_ids): + """Get folder items by ids. + + This function will query folders if they are not in cache. But the + queried items are not added to cache back. + + Args: + project_name (str): Name of project where to look for folders. + folder_ids (Iterable[str]): Folder ids. + + Returns: + dict[str, Union[FolderItem, None]]: Folder items by id. + """ + + folder_ids = set(folder_ids) + if self._folders_items[project_name].is_valid: + cache_data = self._folders_items[project_name].get_data() + return { + folder_id: cache_data.get(folder_id) + for folder_id in folder_ids + } + folders = ayon_api.get_folders( + project_name, + folder_ids=folder_ids, + fields=["id", "name", "label", "parentId", "path", "folderType"] + ) + # Make sure all folder ids are in output + output = {folder_id: None for folder_id in folder_ids} + output.update({ + folder["id"]: _get_folder_item_from_entity(folder) + for folder in folders + }) + return output + + def get_folder_item(self, project_name, folder_id): + """Get folder items by id. + + This function will query folder if they is not in cache. But the + queried items are not added to cache back. + + Args: + project_name (str): Name of project where to look for folders. + folder_id (str): Folder id. + + Returns: + Union[FolderItem, None]: Folder item. + """ + items = self.get_folder_items_by_id( + project_name, [folder_id] + ) + return items.get(folder_id) + def get_task_items(self, project_name, folder_id, sender): if not project_name or not folder_id: return [] @@ -235,23 +334,65 @@ class HierarchyModel(object): self._refresh_tasks_cache(project_name, folder_id, sender) return task_cache.get_data() + def get_folder_entities(self, project_name, folder_ids): + """Get folder entities by ids. + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + + Returns: + dict[str, Any]: Folder entities by id. + """ + + output = {} + folder_ids = set(folder_ids) + if not project_name or not folder_ids: + return output + + folder_ids_to_query = set() + for folder_id in folder_ids: + cache = self._folders_by_id[project_name][folder_id] + if cache.is_valid: + output[folder_id] = cache.get_data() + elif folder_id: + folder_ids_to_query.add(folder_id) + else: + output[folder_id] = None + self._query_folder_entities(project_name, folder_ids_to_query) + for folder_id in folder_ids_to_query: + cache = self._folders_by_id[project_name][folder_id] + output[folder_id] = cache.get_data() + return output + def get_folder_entity(self, project_name, folder_id): - cache = self._folders_by_id[project_name][folder_id] - if not cache.is_valid: - entity = None - if folder_id: - entity = ayon_api.get_folder_by_id(project_name, folder_id) - cache.update_data(entity) - return cache.get_data() + output = self.get_folder_entities(project_name, {folder_id}) + return output[folder_id] + + def get_task_entities(self, project_name, task_ids): + output = {} + task_ids = set(task_ids) + if not project_name or not task_ids: + return output + + task_ids_to_query = set() + for task_id in task_ids: + cache = self._tasks_by_id[project_name][task_id] + if cache.is_valid: + output[task_id] = cache.get_data() + elif task_id: + task_ids_to_query.add(task_id) + else: + output[task_id] = None + self._query_task_entities(project_name, task_ids_to_query) + for task_id in task_ids_to_query: + cache = self._tasks_by_id[project_name][task_id] + output[task_id] = cache.get_data() + return output def get_task_entity(self, project_name, task_id): - cache = self._tasks_by_id[project_name][task_id] - if not cache.is_valid: - entity = None - if task_id: - entity = ayon_api.get_task_by_id(project_name, task_id) - cache.update_data(entity) - return cache.get_data() + output = self.get_task_entities(project_name, {task_id}) + return output[task_id] @contextlib.contextmanager def _folder_refresh_event_manager(self, project_name, sender): @@ -321,6 +462,25 @@ class HierarchyModel(object): hierachy_queue.extend(item["children"] or []) return folder_items + def _query_folder_entities(self, project_name, folder_ids): + if not project_name or not folder_ids: + return + project_cache = self._folders_by_id[project_name] + folders = ayon_api.get_folders(project_name, folder_ids=folder_ids) + for folder in folders: + folder_id = folder["id"] + project_cache[folder_id].update_data(folder) + + def _query_task_entities(self, project_name, task_ids): + if not project_name or not task_ids: + return + + project_cache = self._tasks_by_id[project_name] + tasks = ayon_api.get_tasks(project_name, task_ids=task_ids) + for task in tasks: + task_id = task["id"] + project_cache[task_id].update_data(task) + def _refresh_tasks_cache(self, project_name, folder_id, sender=None): if folder_id in self._tasks_refreshing: return diff --git a/openpype/tools/ayon_utils/models/projects.py b/openpype/tools/ayon_utils/models/projects.py index ae3eeecea4..4ad53fbbfa 100644 --- a/openpype/tools/ayon_utils/models/projects.py +++ b/openpype/tools/ayon_utils/models/projects.py @@ -29,13 +29,14 @@ class ProjectItem: is parent. """ - def __init__(self, name, active, icon=None): + def __init__(self, name, active, is_library, icon=None): self.name = name self.active = active + self.is_library = is_library if icon is None: icon = { "type": "awesome-font", - "name": "fa.map", + "name": "fa.book" if is_library else "fa.map", "color": get_default_entity_icon_color(), } self.icon = icon @@ -50,6 +51,7 @@ class ProjectItem: return { "name": self.name, "active": self.active, + "is_library": self.is_library, "icon": self.icon, } @@ -78,7 +80,7 @@ def _get_project_items_from_entitiy(projects): """ return [ - ProjectItem(project["name"], project["active"]) + ProjectItem(project["name"], project["active"], project["library"]) for project in projects ] @@ -141,5 +143,5 @@ class ProjectsModel(object): self._projects_cache.update_data(project_items) def _query_projects(self): - projects = ayon_api.get_projects(fields=["name", "active"]) + projects = ayon_api.get_projects(fields=["name", "active", "library"]) return _get_project_items_from_entitiy(projects) diff --git a/openpype/tools/ayon_utils/models/thumbnails.py b/openpype/tools/ayon_utils/models/thumbnails.py new file mode 100644 index 0000000000..40892338df --- /dev/null +++ b/openpype/tools/ayon_utils/models/thumbnails.py @@ -0,0 +1,118 @@ +import collections + +import ayon_api + +from openpype.client.server.thumbnails import AYONThumbnailCache + +from .cache import NestedCacheItem + + +class ThumbnailsModel: + entity_cache_lifetime = 240 # In seconds + + def __init__(self): + self._thumbnail_cache = AYONThumbnailCache() + self._paths_cache = collections.defaultdict(dict) + self._folders_cache = NestedCacheItem( + levels=2, lifetime=self.entity_cache_lifetime) + self._versions_cache = NestedCacheItem( + levels=2, lifetime=self.entity_cache_lifetime) + + def reset(self): + self._paths_cache = collections.defaultdict(dict) + self._folders_cache.reset() + self._versions_cache.reset() + + def get_thumbnail_path(self, project_name, thumbnail_id): + return self._get_thumbnail_path(project_name, thumbnail_id) + + def get_folder_thumbnail_ids(self, project_name, folder_ids): + project_cache = self._folders_cache[project_name] + output = {} + missing_cache = set() + for folder_id in folder_ids: + cache = project_cache[folder_id] + if cache.is_valid: + output[folder_id] = cache.get_data() + else: + missing_cache.add(folder_id) + self._query_folder_thumbnail_ids(project_name, missing_cache) + for folder_id in missing_cache: + cache = project_cache[folder_id] + output[folder_id] = cache.get_data() + return output + + def get_version_thumbnail_ids(self, project_name, version_ids): + project_cache = self._versions_cache[project_name] + output = {} + missing_cache = set() + for version_id in version_ids: + cache = project_cache[version_id] + if cache.is_valid: + output[version_id] = cache.get_data() + else: + missing_cache.add(version_id) + self._query_version_thumbnail_ids(project_name, missing_cache) + for version_id in missing_cache: + cache = project_cache[version_id] + output[version_id] = cache.get_data() + return output + + def _get_thumbnail_path(self, project_name, thumbnail_id): + if not thumbnail_id: + return None + + project_cache = self._paths_cache[project_name] + if thumbnail_id in project_cache: + return project_cache[thumbnail_id] + + filepath = self._thumbnail_cache.get_thumbnail_filepath( + project_name, thumbnail_id + ) + if filepath is not None: + project_cache[thumbnail_id] = filepath + return filepath + + # 'ayon_api' had a bug, public function + # 'get_thumbnail_by_id' did not return output of + # 'ServerAPI' method. + con = ayon_api.get_server_api_connection() + result = con.get_thumbnail_by_id(project_name, thumbnail_id) + if result is None: + pass + + elif result.is_valid: + filepath = self._thumbnail_cache.store_thumbnail( + project_name, + thumbnail_id, + result.content, + result.content_type + ) + project_cache[thumbnail_id] = filepath + return filepath + + def _query_folder_thumbnail_ids(self, project_name, folder_ids): + if not project_name or not folder_ids: + return + + folders = ayon_api.get_folders( + project_name, + folder_ids=folder_ids, + fields=["id", "thumbnailId"] + ) + project_cache = self._folders_cache[project_name] + for folder in folders: + project_cache[folder["id"]] = folder["thumbnailId"] + + def _query_version_thumbnail_ids(self, project_name, version_ids): + if not project_name or not version_ids: + return + + versions = ayon_api.get_versions( + project_name, + version_ids=version_ids, + fields=["id", "thumbnailId"] + ) + project_cache = self._versions_cache[project_name] + for version in versions: + project_cache[version["id"]] = version["thumbnailId"] diff --git a/openpype/tools/ayon_utils/widgets/__init__.py b/openpype/tools/ayon_utils/widgets/__init__.py index 59aef98faf..432a249a73 100644 --- a/openpype/tools/ayon_utils/widgets/__init__.py +++ b/openpype/tools/ayon_utils/widgets/__init__.py @@ -8,11 +8,13 @@ from .projects_widget import ( from .folders_widget import ( FoldersWidget, FoldersModel, + FOLDERS_MODEL_SENDER_NAME, ) from .tasks_widget import ( TasksWidget, TasksModel, + TASKS_MODEL_SENDER_NAME, ) from .utils import ( get_qt_icon, @@ -28,9 +30,11 @@ __all__ = ( "FoldersWidget", "FoldersModel", + "FOLDERS_MODEL_SENDER_NAME", "TasksWidget", "TasksModel", + "TASKS_MODEL_SENDER_NAME", "get_qt_icon", "RefreshThread", diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py index 3fab64f657..322553c51c 100644 --- a/openpype/tools/ayon_utils/widgets/folders_widget.py +++ b/openpype/tools/ayon_utils/widgets/folders_widget.py @@ -4,14 +4,16 @@ from qtpy import QtWidgets, QtGui, QtCore from openpype.tools.utils import ( RecursiveSortFilterProxyModel, - DeselectableTreeView, + TreeView, ) from .utils import RefreshThread, get_qt_icon -SENDER_NAME = "qt_folders_model" -ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 -ITEM_NAME_ROLE = QtCore.Qt.UserRole + 2 +FOLDERS_MODEL_SENDER_NAME = "qt_folders_model" +FOLDER_ID_ROLE = QtCore.Qt.UserRole + 1 +FOLDER_NAME_ROLE = QtCore.Qt.UserRole + 2 +FOLDER_PATH_ROLE = QtCore.Qt.UserRole + 3 +FOLDER_TYPE_ROLE = QtCore.Qt.UserRole + 4 class FoldersModel(QtGui.QStandardItemModel): @@ -56,11 +58,21 @@ class FoldersModel(QtGui.QStandardItemModel): return self._has_content - def clear(self): + def refresh(self): + """Refresh folders for last selected project. + + Force to update folders model from controller. This may or may not + trigger query from server, that's based on controller's cache. + """ + + self.set_project_name(self._last_project_name) + + def _clear_items(self): self._items_by_id = {} self._parent_id_by_id = {} self._has_content = False - super(FoldersModel, self).clear() + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) def get_index_by_id(self, item_id): """Get index by folder id. @@ -74,6 +86,15 @@ class FoldersModel(QtGui.QStandardItemModel): return QtCore.QModelIndex() return self.indexFromItem(item) + def get_project_name(self): + """Project name which model currently use. + + Returns: + Union[str, None]: Currently used project name. + """ + + return self._last_project_name + def set_project_name(self, project_name): """Refresh folders items. @@ -90,7 +111,7 @@ class FoldersModel(QtGui.QStandardItemModel): self._is_refreshing = True if self._last_project_name != project_name: - self.clear() + self._clear_items() self._last_project_name = project_name thread = self._refresh_threads.get(project_name) @@ -102,7 +123,7 @@ class FoldersModel(QtGui.QStandardItemModel): project_name, self._controller.get_folder_items, project_name, - SENDER_NAME + FOLDERS_MODEL_SENDER_NAME ) self._current_refresh_thread = thread self._refresh_threads[thread.id] = thread @@ -132,10 +153,26 @@ class FoldersModel(QtGui.QStandardItemModel): self._fill_items(thread.get_result()) + def _fill_item_data(self, item, folder_item): + """ + + Args: + item (QtGui.QStandardItem): Item to fill data. + folder_item (FolderItem): Folder item. + """ + + icon = get_qt_icon(folder_item.icon) + item.setData(folder_item.entity_id, FOLDER_ID_ROLE) + item.setData(folder_item.name, FOLDER_NAME_ROLE) + item.setData(folder_item.path, FOLDER_PATH_ROLE) + item.setData(folder_item.folder_type, FOLDER_TYPE_ROLE) + item.setData(folder_item.label, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + def _fill_items(self, folder_items_by_id): if not folder_items_by_id: if folder_items_by_id is not None: - self.clear() + self._clear_items() self._is_refreshing = False self.refreshed.emit() return @@ -168,7 +205,7 @@ class FoldersModel(QtGui.QStandardItemModel): folder_ids_to_add = set(folder_items) for row_idx in reversed(range(parent_item.rowCount())): child_item = parent_item.child(row_idx) - child_id = child_item.data(ITEM_ID_ROLE) + child_id = child_item.data(FOLDER_ID_ROLE) if child_id in ids_to_remove: removed_items.append(parent_item.takeRow(row_idx)) else: @@ -185,11 +222,7 @@ class FoldersModel(QtGui.QStandardItemModel): else: is_new = self._parent_id_by_id[item_id] != parent_id - icon = get_qt_icon(folder_item.icon) - item.setData(item_id, ITEM_ID_ROLE) - item.setData(folder_item.name, ITEM_NAME_ROLE) - item.setData(folder_item.label, QtCore.Qt.DisplayRole) - item.setData(icon, QtCore.Qt.DecorationRole) + self._fill_item_data(item, folder_item) if is_new: new_items.append(item) self._items_by_id[item_id] = item @@ -238,15 +271,20 @@ class FoldersWidget(QtWidgets.QWidget): the expected selection. Defaults to False. """ + double_clicked = QtCore.Signal(QtGui.QMouseEvent) + selection_changed = QtCore.Signal() + refreshed = QtCore.Signal() + def __init__(self, controller, parent, handle_expected_selection=False): super(FoldersWidget, self).__init__(parent) - folders_view = DeselectableTreeView(self) + folders_view = TreeView(self) folders_view.setHeaderHidden(True) folders_model = FoldersModel(controller) folders_proxy_model = RecursiveSortFilterProxyModel() folders_proxy_model.setSourceModel(folders_model) + folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) folders_view.setModel(folders_proxy_model) @@ -273,7 +311,7 @@ class FoldersWidget(QtWidgets.QWidget): selection_model = folders_view.selectionModel() selection_model.selectionChanged.connect(self._on_selection_change) - + folders_view.double_clicked.connect(self.double_clicked) folders_model.refreshed.connect(self._on_model_refresh) self._controller = controller @@ -284,7 +322,27 @@ class FoldersWidget(QtWidgets.QWidget): self._handle_expected_selection = handle_expected_selection self._expected_selection = None - def set_name_filer(self, name): + @property + def is_refreshing(self): + """Model is refreshing. + + Returns: + bool: True if model is refreshing. + """ + + return self._folders_model.is_refreshing + + @property + def has_content(self): + """Has at least one folder. + + Returns: + bool: True if model has at least one folder. + """ + + return self._folders_model.has_content + + def set_name_filter(self, name): """Set filter of folder name. Args: @@ -293,19 +351,116 @@ class FoldersWidget(QtWidgets.QWidget): self._folders_proxy_model.setFilterFixedString(name) - def _on_project_selection_change(self, event): - project_name = event["project_name"] - self._set_project_name(project_name) + def refresh(self): + """Refresh folders model. + + Force to update folders model from controller. + """ + + self._folders_model.refresh() + + def get_project_name(self): + """Project name in which folders widget currently is. + + Returns: + Union[str, None]: Currently used project name. + """ + + return self._folders_model.get_project_name() + + def set_project_name(self, project_name): + """Set project name. + + Do not use this method when controller is handling selection of + project using 'selection.project.changed' event. + + Args: + project_name (str): Project name. + """ - def _set_project_name(self, project_name): self._folders_model.set_project_name(project_name) - def _clear(self): - self._folders_model.clear() + def get_selected_folder_id(self): + """Get selected folder id. + + Returns: + Union[str, None]: Folder id which is selected. + """ + + return self._get_selected_item_id() + + def get_selected_folder_label(self): + """Selected folder label. + + Returns: + Union[str, None]: Selected folder label. + """ + + item_id = self._get_selected_item_id() + return self.get_folder_label(item_id) + + def get_folder_label(self, folder_id): + """Folder label for a given folder id. + + Returns: + Union[str, None]: Folder label. + """ + + index = self._folders_model.get_index_by_id(folder_id) + if index.isValid(): + return index.data(QtCore.Qt.DisplayRole) + return None + + def set_selected_folder(self, folder_id): + """Change selection. + + Args: + folder_id (Union[str, None]): Folder id or None to deselect. + """ + + if folder_id is None: + self._folders_view.clearSelection() + return True + + if folder_id == self._get_selected_item_id(): + return True + index = self._folders_model.get_index_by_id(folder_id) + if not index.isValid(): + return False + + proxy_index = self._folders_proxy_model.mapFromSource(index) + if not proxy_index.isValid(): + return False + + selection_model = self._folders_view.selectionModel() + selection_model.setCurrentIndex( + proxy_index, QtCore.QItemSelectionModel.SelectCurrent + ) + return True + + def set_deselectable(self, enabled): + """Set deselectable mode. + + Items in view can be deselected. + + Args: + enabled (bool): Enable deselectable mode. + """ + + self._folders_view.set_deselectable(enabled) + + def _get_selected_index(self): + return self._folders_model.get_index_by_id( + self.get_selected_folder_id() + ) + + def _on_project_selection_change(self, event): + project_name = event["project_name"] + self.set_project_name(project_name) def _on_folders_refresh_finished(self, event): - if event["sender"] != SENDER_NAME: - self._set_project_name(event["project_name"]) + if event["sender"] != FOLDERS_MODEL_SENDER_NAME: + self.set_project_name(event["project_name"]) def _on_controller_refresh(self): self._update_expected_selection() @@ -314,11 +469,12 @@ class FoldersWidget(QtWidgets.QWidget): if self._expected_selection: self._set_expected_selection() self._folders_proxy_model.sort(0) + self.refreshed.emit() def _get_selected_item_id(self): selection_model = self._folders_view.selectionModel() for index in selection_model.selectedIndexes(): - item_id = index.data(ITEM_ID_ROLE) + item_id = index.data(FOLDER_ID_ROLE) if item_id is not None: return item_id return None @@ -326,6 +482,7 @@ class FoldersWidget(QtWidgets.QWidget): def _on_selection_change(self): item_id = self._get_selected_item_id() self._controller.set_selected_folder(item_id) + self.selection_changed.emit() # Expected selection handling def _on_expected_selection_change(self, event): @@ -353,12 +510,6 @@ class FoldersWidget(QtWidgets.QWidget): folder_id = self._expected_selection self._expected_selection = None - if ( - folder_id is not None - and folder_id != self._get_selected_item_id() - ): - index = self._folders_model.get_index_by_id(folder_id) - if index.isValid(): - proxy_index = self._folders_proxy_model.mapFromSource(index) - self._folders_view.setCurrentIndex(proxy_index) + if folder_id is not None: + self.set_selected_folder(folder_id) self._controller.expected_folder_selected(folder_id) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 818d574910..be18cfe3ed 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -5,6 +5,9 @@ from .utils import RefreshThread, get_qt_icon PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2 +PROJECT_IS_LIBRARY_ROLE = QtCore.Qt.UserRole + 3 +PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4 +LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 5 class ProjectsModel(QtGui.QStandardItemModel): @@ -15,10 +18,23 @@ class ProjectsModel(QtGui.QStandardItemModel): self._controller = controller self._project_items = {} + self._has_libraries = False self._empty_item = None self._empty_item_added = False + self._select_item = None + self._select_item_added = False + self._select_item_visible = None + + self._libraries_sep_item = None + self._libraries_sep_item_added = False + self._libraries_sep_item_visible = False + + self._current_context_project = None + + self._selected_project = None + self._is_refreshing = False self._refresh_thread = None @@ -32,21 +48,63 @@ class ProjectsModel(QtGui.QStandardItemModel): def has_content(self): return len(self._project_items) > 0 + def set_select_item_visible(self, visible): + if self._select_item_visible is visible: + return + self._select_item_visible = visible + + if self._selected_project is None: + self._add_select_item() + + def set_libraries_separator_visible(self, visible): + if self._libraries_sep_item_visible is visible: + return + self._libraries_sep_item_visible = visible + + def set_selected_project(self, project_name): + if not self._select_item_visible: + return + + self._selected_project = project_name + if project_name is None: + self._add_select_item() + else: + self._remove_select_item() + + def set_current_context_project(self, project_name): + if project_name == self._current_context_project: + return + self._unset_current_context_project(self._current_context_project) + self._current_context_project = project_name + self._set_current_context_project(project_name) + + def _set_current_context_project(self, project_name): + item = self._project_items.get(project_name) + if item is None: + return + item.setData(True, PROJECT_IS_CURRENT_ROLE) + + def _unset_current_context_project(self, project_name): + item = self._project_items.get(project_name) + if item is None: + return + item.setData(False, PROJECT_IS_CURRENT_ROLE) + def _add_empty_item(self): + if self._empty_item_added: + return + self._empty_item_added = True item = self._get_empty_item() - if not self._empty_item_added: - root_item = self.invisibleRootItem() - root_item.appendRow(item) - self._empty_item_added = True + root_item = self.invisibleRootItem() + root_item.appendRow(item) def _remove_empty_item(self): if not self._empty_item_added: return - + self._empty_item_added = False root_item = self.invisibleRootItem() item = self._get_empty_item() root_item.takeRow(item.row()) - self._empty_item_added = False def _get_empty_item(self): if self._empty_item is None: @@ -55,6 +113,61 @@ class ProjectsModel(QtGui.QStandardItemModel): self._empty_item = item return self._empty_item + def _get_library_sep_item(self): + if self._libraries_sep_item is not None: + return self._libraries_sep_item + + item = QtGui.QStandardItem() + item.setData("Libraries", QtCore.Qt.DisplayRole) + item.setData(True, LIBRARY_PROJECT_SEPARATOR_ROLE) + item.setFlags(QtCore.Qt.NoItemFlags) + self._libraries_sep_item = item + return item + + def _add_library_sep_item(self): + if ( + not self._libraries_sep_item_visible + or self._libraries_sep_item_added + ): + return + self._libraries_sep_item_added = True + item = self._get_library_sep_item() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _remove_library_sep_item(self): + if ( + not self._libraries_sep_item_added + ): + return + self._libraries_sep_item_added = False + item = self._get_library_sep_item() + root_item = self.invisibleRootItem() + root_item.takeRow(item.row()) + + def _add_select_item(self): + if self._select_item_added: + return + self._select_item_added = True + item = self._get_select_item() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _remove_select_item(self): + if not self._select_item_added: + return + self._select_item_added = False + root_item = self.invisibleRootItem() + item = self._get_select_item() + root_item.takeRow(item.row()) + + def _get_select_item(self): + if self._select_item is None: + item = QtGui.QStandardItem("< Select project >") + item.setEditable(False) + self._select_item = item + return self._select_item + def _refresh(self): if self._is_refreshing: return @@ -80,44 +193,118 @@ class ProjectsModel(QtGui.QStandardItemModel): self.refreshed.emit() def _fill_items(self, project_items): - items_to_remove = set(self._project_items.keys()) + new_project_names = { + project_item.name + for project_item in project_items + } + + # Handle "Select item" visibility + if self._select_item_visible: + # Add select project. if previously selected project is not in + # project items + if self._selected_project not in new_project_names: + self._add_select_item() + else: + self._remove_select_item() + + root_item = self.invisibleRootItem() + + items_to_remove = set(self._project_items.keys()) - new_project_names + for project_name in items_to_remove: + item = self._project_items.pop(project_name) + root_item.takeRow(item.row()) + + has_library_project = False new_items = [] for project_item in project_items: project_name = project_item.name - items_to_remove.discard(project_name) item = self._project_items.get(project_name) + if project_item.is_library: + has_library_project = True if item is None: item = QtGui.QStandardItem() + item.setEditable(False) new_items.append(item) icon = get_qt_icon(project_item.icon) item.setData(project_name, QtCore.Qt.DisplayRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(project_name, PROJECT_NAME_ROLE) item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE) + item.setData(project_item.is_library, PROJECT_IS_LIBRARY_ROLE) + is_current = project_name == self._current_context_project + item.setData(is_current, PROJECT_IS_CURRENT_ROLE) self._project_items[project_name] = item - root_item = self.invisibleRootItem() + self._set_current_context_project(self._current_context_project) + + self._has_libraries = has_library_project + if new_items: root_item.appendRows(new_items) - for project_name in items_to_remove: - item = self._project_items.pop(project_name) - root_item.removeRow(item.row()) - if self.has_content(): + # Make sure "No projects" item is removed self._remove_empty_item() + if has_library_project: + self._add_library_sep_item() + else: + self._remove_library_sep_item() else: + # Keep only "No projects" item self._add_empty_item() + self._remove_select_item() + self._remove_library_sep_item() class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): def __init__(self, *args, **kwargs): super(ProjectSortFilterProxy, self).__init__(*args, **kwargs) self._filter_inactive = True + self._filter_standard = False + self._filter_library = False + self._sort_by_type = True # Disable case sensitivity self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + def _type_sort(self, l_index, r_index): + if not self._sort_by_type: + return None + + l_is_library = l_index.data(PROJECT_IS_LIBRARY_ROLE) + r_is_library = r_index.data(PROJECT_IS_LIBRARY_ROLE) + # Both hare project items + if l_is_library is not None and r_is_library is not None: + if l_is_library is r_is_library: + return None + if l_is_library: + return False + return True + + if l_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE): + if r_is_library is None: + return False + return r_is_library + + if r_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE): + if l_is_library is None: + return True + return l_is_library + return None + def lessThan(self, left_index, right_index): + # Current project always on top + # - make sure this is always first, before any other sorting + # e.g. type sort would move the item lower + if left_index.data(PROJECT_IS_CURRENT_ROLE): + return True + if right_index.data(PROJECT_IS_CURRENT_ROLE): + return False + + # Library separator should be before library projects + result = self._type_sort(left_index, right_index) + if result is not None: + return result + if left_index.data(PROJECT_NAME_ROLE) is None: return True @@ -137,21 +324,43 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): def filterAcceptsRow(self, source_row, source_parent): index = self.sourceModel().index(source_row, 0, source_parent) + project_name = index.data(PROJECT_NAME_ROLE) + if project_name is None: + return True + string_pattern = self.filterRegularExpression().pattern() + if string_pattern: + return string_pattern.lower() in project_name.lower() + + # Current project keep always visible + default = super(ProjectSortFilterProxy, self).filterAcceptsRow( + source_row, source_parent + ) + if not default: + return default + + # Make sure current project is visible + if index.data(PROJECT_IS_CURRENT_ROLE): + return True + if ( self._filter_inactive and not index.data(PROJECT_IS_ACTIVE_ROLE) ): return False - if string_pattern: - project_name = index.data(PROJECT_IS_ACTIVE_ROLE) - if project_name is not None: - return string_pattern.lower() in project_name.lower() + if ( + self._filter_standard + and not index.data(PROJECT_IS_LIBRARY_ROLE) + ): + return False - return super(ProjectSortFilterProxy, self).filterAcceptsRow( - source_row, source_parent - ) + if ( + self._filter_library + and index.data(PROJECT_IS_LIBRARY_ROLE) + ): + return False + return True def _custom_index_filter(self, index): return bool(index.data(PROJECT_IS_ACTIVE_ROLE)) @@ -159,14 +368,35 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): def is_active_filter_enabled(self): return self._filter_inactive - def set_active_filter_enabled(self, value): - if self._filter_inactive == value: + def set_active_filter_enabled(self, enabled): + if self._filter_inactive == enabled: return - self._filter_inactive = value + self._filter_inactive = enabled self.invalidateFilter() + def set_library_filter_enabled(self, enabled): + if self._filter_library == enabled: + return + self._filter_library = enabled + self.invalidateFilter() + + def set_standard_filter_enabled(self, enabled): + if self._filter_standard == enabled: + return + self._filter_standard = enabled + self.invalidateFilter() + + def set_sort_by_type(self, enabled): + if self._sort_by_type is enabled: + return + self._sort_by_type = enabled + self.invalidate() + class ProjectsCombobox(QtWidgets.QWidget): + refreshed = QtCore.Signal() + selection_changed = QtCore.Signal() + def __init__(self, controller, parent, handle_expected_selection=False): super(ProjectsCombobox, self).__init__(parent) @@ -203,6 +433,7 @@ class ProjectsCombobox(QtWidgets.QWidget): self._controller = controller self._listen_selection_change = True + self._select_item_visible = False self._handle_expected_selection = handle_expected_selection self._expected_selection = None @@ -252,7 +483,7 @@ class ProjectsCombobox(QtWidgets.QWidget): self._listen_selection_change = listen - def get_current_project_name(self): + def get_selected_project_name(self): """Name of selected project. Returns: @@ -264,17 +495,57 @@ class ProjectsCombobox(QtWidgets.QWidget): return None return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE) + def set_current_context_project(self, project_name): + self._projects_model.set_current_context_project(project_name) + self._projects_proxy_model.invalidateFilter() + + def _update_select_item_visiblity(self, **kwargs): + if not self._select_item_visible: + return + if "project_name" not in kwargs: + project_name = self.get_selected_project_name() + else: + project_name = kwargs.get("project_name") + + # Hide the item if a project is selected + self._projects_model.set_selected_project(project_name) + + def set_select_item_visible(self, visible): + self._select_item_visible = visible + self._projects_model.set_select_item_visible(visible) + self._update_select_item_visiblity() + + def set_libraries_separator_visible(self, visible): + self._projects_model.set_libraries_separator_visible(visible) + + def is_active_filter_enabled(self): + return self._projects_proxy_model.is_active_filter_enabled() + + def set_active_filter_enabled(self, enabled): + return self._projects_proxy_model.set_active_filter_enabled(enabled) + + def set_standard_filter_enabled(self, enabled): + return self._projects_proxy_model.set_standard_filter_enabled(enabled) + + def set_library_filter_enabled(self, enabled): + return self._projects_proxy_model.set_library_filter_enabled(enabled) + def _on_current_index_changed(self, idx): if not self._listen_selection_change: return project_name = self._projects_combobox.itemData( idx, PROJECT_NAME_ROLE) + self._update_select_item_visiblity(project_name=project_name) self._controller.set_selected_project(project_name) + self.selection_changed.emit() def _on_model_refresh(self): self._projects_proxy_model.sort(0) + self._projects_proxy_model.invalidateFilter() if self._expected_selection: self._set_expected_selection() + self._update_select_item_visiblity() + self.refreshed.emit() def _on_projects_refresh_finished(self, event): if event["sender"] != PROJECTS_MODEL_SENDER: @@ -292,7 +563,7 @@ class ProjectsCombobox(QtWidgets.QWidget): return project_name = self._expected_selection if project_name is not None: - if project_name != self.get_current_project_name(): + if project_name != self.get_selected_project_name(): self.set_selection(project_name) else: # Fake project change diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py index 66ebd0b777..d01b3a7917 100644 --- a/openpype/tools/ayon_utils/widgets/tasks_widget.py +++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py @@ -5,7 +5,7 @@ from openpype.tools.utils import DeselectableTreeView from .utils import RefreshThread, get_qt_icon -SENDER_NAME = "qt_tasks_model" +TASKS_MODEL_SENDER_NAME = "qt_tasks_model" ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 PARENT_ID_ROLE = QtCore.Qt.UserRole + 2 ITEM_NAME_ROLE = QtCore.Qt.UserRole + 3 @@ -44,14 +44,20 @@ class TasksModel(QtGui.QStandardItemModel): # Initial state self._add_invalid_selection_item() - def clear(self): + def _clear_items(self): self._items_by_name = {} self._has_content = False self._remove_invalid_items() - super(TasksModel, self).clear() + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) - def refresh(self, project_name, folder_id): - """Refresh tasks for folder. + def refresh(self): + """Refresh tasks for last project and folder.""" + + self._refresh(self._last_project_name, self._last_folder_id) + + def set_context(self, project_name, folder_id): + """Set context for which should be tasks showed. Args: project_name (Union[str]): Name of project. @@ -121,7 +127,7 @@ class TasksModel(QtGui.QStandardItemModel): return self._empty_tasks_item def _add_invalid_item(self, item): - self.clear() + self._clear_items() root_item = self.invisibleRootItem() root_item.appendRow(item) @@ -290,6 +296,9 @@ class TasksWidget(QtWidgets.QWidget): handle_expected_selection (Optional[bool]): Handle expected selection. """ + refreshed = QtCore.Signal() + selection_changed = QtCore.Signal() + def __init__(self, controller, parent, handle_expected_selection=False): super(TasksWidget, self).__init__(parent) @@ -299,6 +308,7 @@ class TasksWidget(QtWidgets.QWidget): tasks_model = TasksModel(controller) tasks_proxy_model = QtCore.QSortFilterProxyModel() tasks_proxy_model.setSourceModel(tasks_model) + tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) tasks_view.setModel(tasks_proxy_model) @@ -334,8 +344,14 @@ class TasksWidget(QtWidgets.QWidget): self._handle_expected_selection = handle_expected_selection self._expected_selection_data = None - def _clear(self): - self._tasks_model.clear() + def refresh(self): + """Refresh folders for last selected project. + + Force to update folders model from controller. This may or may not + trigger query from server, that's based on controller's cache. + """ + + self._tasks_model.refresh() def _on_tasks_refresh_finished(self, event): """Tasks were refreshed in controller. @@ -349,17 +365,17 @@ class TasksWidget(QtWidgets.QWidget): # Refresh only if current folder id is the same if ( - event["sender"] == SENDER_NAME + event["sender"] == TASKS_MODEL_SENDER_NAME or event["folder_id"] != self._selected_folder_id ): return - self._tasks_model.refresh( + self._tasks_model.set_context( event["project_name"], self._selected_folder_id ) def _folder_selection_changed(self, event): self._selected_folder_id = event["folder_id"] - self._tasks_model.refresh( + self._tasks_model.set_context( event["project_name"], self._selected_folder_id ) @@ -367,6 +383,7 @@ class TasksWidget(QtWidgets.QWidget): if not self._set_expected_selection(): self._on_selection_change() self._tasks_proxy_model.sort(0) + self.refreshed.emit() def _get_selected_item_ids(self): selection_model = self._tasks_view.selectionModel() @@ -387,6 +404,7 @@ class TasksWidget(QtWidgets.QWidget): parent_id, task_id, task_name = self._get_selected_item_ids() self._controller.set_selected_task(task_id, task_name) + self.selection_changed.emit() # Expected selection handling def _on_expected_selection_change(self, event): diff --git a/openpype/tools/ayon_workfiles/abstract.py b/openpype/tools/ayon_workfiles/abstract.py index f511181837..ce399fd4c6 100644 --- a/openpype/tools/ayon_workfiles/abstract.py +++ b/openpype/tools/ayon_workfiles/abstract.py @@ -914,10 +914,12 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): # Controller actions @abstractmethod - def open_workfile(self, filepath): - """Open a workfile. + def open_workfile(self, folder_id, task_id, filepath): + """Open a workfile for context. Args: + folder_id (str): Folder id. + task_id (str): Task id. filepath (str): Workfile path. """ diff --git a/openpype/tools/ayon_workfiles/control.py b/openpype/tools/ayon_workfiles/control.py index 1153a3c01f..3784959caf 100644 --- a/openpype/tools/ayon_workfiles/control.py +++ b/openpype/tools/ayon_workfiles/control.py @@ -452,12 +452,12 @@ class BaseWorkfileController( self._emit_event("controller.refresh.finished") # Controller actions - def open_workfile(self, filepath): + def open_workfile(self, folder_id, task_id, filepath): self._emit_event("open_workfile.started") failed = False try: - self._host_open_workfile(filepath) + self._open_workfile(folder_id, task_id, filepath) except Exception: failed = True @@ -575,6 +575,53 @@ class BaseWorkfileController( self._expected_selection.get_expected_selection_data(), ) + def _get_event_context_data( + self, project_name, folder_id, task_id, folder=None, task=None + ): + if folder is None: + folder = self.get_folder_entity(folder_id) + if task is None: + task = self.get_task_entity(task_id) + # NOTE keys should be OpenPype compatible + return { + "project_name": project_name, + "folder_id": folder_id, + "asset_id": folder_id, + "asset_name": folder["name"], + "task_id": task_id, + "task_name": task["name"], + "host_name": self.get_host_name(), + } + + def _open_workfile(self, folder_id, task_id, filepath): + project_name = self.get_current_project_name() + event_data = self._get_event_context_data( + project_name, folder_id, task_id + ) + event_data["filepath"] = filepath + + emit_event("workfile.open.before", event_data, source="workfiles.tool") + + # Change context + task_name = event_data["task_name"] + if ( + folder_id != self.get_current_folder_id() + or task_name != self.get_current_task_name() + ): + # Use OpenPype asset-like object + asset_doc = get_asset_by_id( + event_data["project_name"], + event_data["folder_id"], + ) + change_current_context( + asset_doc, + event_data["task_name"] + ) + + self._host_open_workfile(filepath) + + emit_event("workfile.open.after", event_data, source="workfiles.tool") + def _save_as_workfile( self, folder_id, @@ -591,18 +638,14 @@ class BaseWorkfileController( task_name = task["name"] # QUESTION should the data be different for 'before' and 'after'? - # NOTE keys should be OpenPype compatible - event_data = { - "project_name": project_name, - "folder_id": folder_id, - "asset_id": folder_id, - "asset_name": folder["name"], - "task_id": task_id, - "task_name": task_name, - "host_name": self.get_host_name(), + event_data = self._get_event_context_data( + project_name, folder_id, task_id, folder, task + ) + event_data.update({ "filename": filename, "workdir_path": workdir, - } + }) + emit_event("workfile.save.before", event_data, source="workfiles.tool") # Create workfiles root folder diff --git a/openpype/tools/ayon_workfiles/models/workfiles.py b/openpype/tools/ayon_workfiles/models/workfiles.py index 316d8b2a16..4d989ed22c 100644 --- a/openpype/tools/ayon_workfiles/models/workfiles.py +++ b/openpype/tools/ayon_workfiles/models/workfiles.py @@ -48,7 +48,7 @@ def get_task_template_data(project_entity, task): return {} short_name = None task_type_name = task["taskType"] - for task_type_info in project_entity["config"]["taskTypes"]: + for task_type_info in project_entity["taskTypes"]: if task_type_info["name"] == task_type_name: short_name = task_type_info["shortName"] break diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget.py b/openpype/tools/ayon_workfiles/widgets/files_widget.py index fbf4dbc593..656ddf1dd8 100644 --- a/openpype/tools/ayon_workfiles/widgets/files_widget.py +++ b/openpype/tools/ayon_workfiles/widgets/files_widget.py @@ -106,7 +106,8 @@ class FilesWidget(QtWidgets.QWidget): self._on_published_cancel_clicked) self._selected_folder_id = None - self._selected_tak_name = None + self._selected_task_id = None + self._selected_task_name = None self._pre_select_folder_id = None self._pre_select_task_name = None @@ -178,7 +179,7 @@ class FilesWidget(QtWidgets.QWidget): # ------------------------------------------------------------- # Workarea workfiles # ------------------------------------------------------------- - def _open_workfile(self, filepath): + def _open_workfile(self, folder_id, task_name, filepath): if self._controller.has_unsaved_changes(): result = self._save_changes_prompt() if result is None: @@ -186,12 +187,15 @@ class FilesWidget(QtWidgets.QWidget): if result: self._controller.save_current_workfile() - self._controller.open_workfile(filepath) + self._controller.open_workfile(folder_id, task_name, filepath) def _on_workarea_open_clicked(self): path = self._workarea_widget.get_selected_path() - if path: - self._open_workfile(path) + if not path: + return + folder_id = self._selected_folder_id + task_id = self._selected_task_id + self._open_workfile(folder_id, task_id, path) def _on_current_open_requests(self): self._on_workarea_open_clicked() @@ -238,8 +242,12 @@ class FilesWidget(QtWidgets.QWidget): } filepath = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0] - if filepath: - self._open_workfile(filepath) + if not filepath: + return + + folder_id = self._selected_folder_id + task_id = self._selected_task_id + self._open_workfile(folder_id, task_id, filepath) def _on_workarea_save_clicked(self): result = self._exec_save_as_dialog() @@ -279,10 +287,11 @@ class FilesWidget(QtWidgets.QWidget): def _on_task_changed(self, event): self._selected_folder_id = event["folder_id"] - self._selected_tak_name = event["task_name"] + self._selected_task_id = event["task_id"] + self._selected_task_name = event["task_name"] self._valid_selected_context = ( self._selected_folder_id is not None - and self._selected_tak_name is not None + and self._selected_task_id is not None ) self._update_published_btns_state() @@ -311,7 +320,7 @@ class FilesWidget(QtWidgets.QWidget): if enabled: self._pre_select_folder_id = self._selected_folder_id - self._pre_select_task_name = self._selected_tak_name + self._pre_select_task_name = self._selected_task_name else: self._pre_select_folder_id = None self._pre_select_task_name = None @@ -334,7 +343,7 @@ class FilesWidget(QtWidgets.QWidget): return True if self._pre_select_task_name is None: return False - return self._pre_select_task_name != self._selected_tak_name + return self._pre_select_task_name != self._selected_task_name def _on_published_cancel_clicked(self): folder_id = self._pre_select_folder_id diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_published.py b/openpype/tools/ayon_workfiles/widgets/files_widget_published.py index bc59447777..576cf18d73 100644 --- a/openpype/tools/ayon_workfiles/widgets/files_widget_published.py +++ b/openpype/tools/ayon_workfiles/widgets/files_widget_published.py @@ -5,9 +5,10 @@ from openpype.style import ( get_default_entity_icon_color, get_disabled_entity_icon_color, ) +from openpype.tools.utils import TreeView from openpype.tools.utils.delegates import PrettyTimeDelegate -from .utils import TreeView, BaseOverlayFrame +from .utils import BaseOverlayFrame REPRE_ID_ROLE = QtCore.Qt.UserRole + 1 @@ -306,7 +307,7 @@ class PublishedFilesWidget(QtWidgets.QWidget): selection_model = view.selectionModel() selection_model.selectionChanged.connect(self._on_selection_change) - view.double_clicked_left.connect(self._on_left_double_click) + view.double_clicked.connect(self._on_mouse_double_click) controller.register_event_callback( "expected_selection_changed", @@ -350,8 +351,9 @@ class PublishedFilesWidget(QtWidgets.QWidget): repre_id = self.get_selected_repre_id() self._controller.set_selected_representation_id(repre_id) - def _on_left_double_click(self): - self.save_as_requested.emit() + def _on_mouse_double_click(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.save_as_requested.emit() def _on_expected_selection_change(self, event): if ( diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py index e8ccd094d1..e59b319459 100644 --- a/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py +++ b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py @@ -5,10 +5,9 @@ from openpype.style import ( get_default_entity_icon_color, get_disabled_entity_icon_color, ) +from openpype.tools.utils import TreeView from openpype.tools.utils.delegates import PrettyTimeDelegate -from .utils import TreeView - FILENAME_ROLE = QtCore.Qt.UserRole + 1 FILEPATH_ROLE = QtCore.Qt.UserRole + 2 DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3 @@ -271,7 +270,7 @@ class WorkAreaFilesWidget(QtWidgets.QWidget): selection_model = view.selectionModel() selection_model.selectionChanged.connect(self._on_selection_change) - view.double_clicked_left.connect(self._on_left_double_click) + view.double_clicked.connect(self._on_mouse_double_click) view.customContextMenuRequested.connect(self._on_context_menu) controller.register_event_callback( @@ -333,8 +332,9 @@ class WorkAreaFilesWidget(QtWidgets.QWidget): filepath = self.get_selected_path() self._controller.set_selected_workfile_path(filepath) - def _on_left_double_click(self): - self.open_current_requested.emit() + def _on_mouse_double_click(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.save_as_requested.emit() def _on_context_menu(self, point): index = self._view.indexAt(point) diff --git a/openpype/tools/ayon_workfiles/widgets/folders_widget.py b/openpype/tools/ayon_workfiles/widgets/folders_widget.py index b35845f4b6..b04f8e4098 100644 --- a/openpype/tools/ayon_workfiles/widgets/folders_widget.py +++ b/openpype/tools/ayon_workfiles/widgets/folders_widget.py @@ -264,7 +264,7 @@ class FoldersWidget(QtWidgets.QWidget): self._expected_selection = None - def set_name_filer(self, name): + def set_name_filter(self, name): self._folders_proxy_model.setFilterFixedString(name) def _clear(self): diff --git a/openpype/tools/ayon_workfiles/widgets/utils.py b/openpype/tools/ayon_workfiles/widgets/utils.py index 6a61239f8d..9171638546 100644 --- a/openpype/tools/ayon_workfiles/widgets/utils.py +++ b/openpype/tools/ayon_workfiles/widgets/utils.py @@ -1,70 +1,4 @@ from qtpy import QtWidgets, QtCore -from openpype.tools.flickcharm import FlickCharm - - -class TreeView(QtWidgets.QTreeView): - """Ultimate TreeView with flick charm and double click signals. - - Tree view have deselectable mode, which allows to deselect items by - clicking on item area without any items. - - Todos: - Add to tools utils. - """ - - double_clicked_left = QtCore.Signal() - double_clicked_right = QtCore.Signal() - - def __init__(self, *args, **kwargs): - super(TreeView, self).__init__(*args, **kwargs) - self._deselectable = False - - self._flick_charm_activated = False - self._flick_charm = FlickCharm(parent=self) - self._before_flick_scroll_mode = None - - def is_deselectable(self): - return self._deselectable - - def set_deselectable(self, deselectable): - self._deselectable = deselectable - - deselectable = property(is_deselectable, set_deselectable) - - def mousePressEvent(self, event): - if self._deselectable: - index = self.indexAt(event.pos()) - if not index.isValid(): - # clear the selection - self.clearSelection() - # clear the current index - self.setCurrentIndex(QtCore.QModelIndex()) - super(TreeView, self).mousePressEvent(event) - - def mouseDoubleClickEvent(self, event): - if event.button() == QtCore.Qt.LeftButton: - self.double_clicked_left.emit() - - elif event.button() == QtCore.Qt.RightButton: - self.double_clicked_right.emit() - - return super(TreeView, self).mouseDoubleClickEvent(event) - - def activate_flick_charm(self): - if self._flick_charm_activated: - return - self._flick_charm_activated = True - self._before_flick_scroll_mode = self.verticalScrollMode() - self._flick_charm.activateOn(self) - self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) - - def deactivate_flick_charm(self): - if not self._flick_charm_activated: - return - self._flick_charm_activated = False - self._flick_charm.deactivateFrom(self) - if self._before_flick_scroll_mode is not None: - self.setVerticalScrollMode(self._before_flick_scroll_mode) class BaseOverlayFrame(QtWidgets.QFrame): diff --git a/openpype/tools/ayon_workfiles/widgets/window.py b/openpype/tools/ayon_workfiles/widgets/window.py index ef352c8b18..6218d2dd06 100644 --- a/openpype/tools/ayon_workfiles/widgets/window.py +++ b/openpype/tools/ayon_workfiles/widgets/window.py @@ -338,7 +338,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget): self._side_panel.set_published_mode(published_mode) def _on_folder_filter_change(self, text): - self._folder_widget.set_name_filer(text) + self._folder_widget.set_name_filter(text) def _on_go_to_current_clicked(self): self._controller.go_to_current_context() diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index d4e0ae0453..a6264303d5 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -176,11 +176,10 @@ class PublishReportMaker: self._create_discover_result = None self._convert_discover_result = None self._publish_discover_result = None - self._plugin_data = [] - self._plugin_data_with_plugin = [] - self._stored_plugins = [] - self._current_plugin_data = [] + self._plugin_data_by_id = {} + self._current_plugin = None + self._current_plugin_data = {} self._all_instances_by_id = {} self._current_context = None @@ -192,8 +191,9 @@ class PublishReportMaker: create_context.convertor_discover_result ) self._publish_discover_result = create_context.publish_discover_result - self._plugin_data = [] - self._plugin_data_with_plugin = [] + + self._plugin_data_by_id = {} + self._current_plugin = None self._current_plugin_data = {} self._all_instances_by_id = {} self._current_context = context @@ -210,18 +210,11 @@ class PublishReportMaker: if self._current_plugin_data: self._current_plugin_data["passed"] = True + self._current_plugin = plugin self._current_plugin_data = self._add_plugin_data_item(plugin) - def _get_plugin_data_item(self, plugin): - store_item = None - for item in self._plugin_data_with_plugin: - if item["plugin"] is plugin: - store_item = item["data"] - break - return store_item - def _add_plugin_data_item(self, plugin): - if plugin in self._stored_plugins: + if plugin.id in self._plugin_data_by_id: # A plugin would be processed more than once. What can cause it: # - there is a bug in controller # - plugin class is imported into multiple files @@ -229,15 +222,9 @@ class PublishReportMaker: raise ValueError( "Plugin '{}' is already stored".format(str(plugin))) - self._stored_plugins.append(plugin) - plugin_data_item = self._create_plugin_data_item(plugin) + self._plugin_data_by_id[plugin.id] = plugin_data_item - self._plugin_data_with_plugin.append({ - "plugin": plugin, - "data": plugin_data_item - }) - self._plugin_data.append(plugin_data_item) return plugin_data_item def _create_plugin_data_item(self, plugin): @@ -278,7 +265,7 @@ class PublishReportMaker: """Add result of single action.""" plugin = result["plugin"] - store_item = self._get_plugin_data_item(plugin) + store_item = self._plugin_data_by_id.get(plugin.id) if store_item is None: store_item = self._add_plugin_data_item(plugin) @@ -300,14 +287,24 @@ class PublishReportMaker: instance, instance in self._current_context ) - plugins_data = copy.deepcopy(self._plugin_data) - if plugins_data and not plugins_data[-1]["passed"]: - plugins_data[-1]["passed"] = True + plugins_data_by_id = copy.deepcopy( + self._plugin_data_by_id + ) + + # Ensure the current plug-in is marked as `passed` in the result + # so that it shows on reports for paused publishes + if self._current_plugin is not None: + current_plugin_data = plugins_data_by_id.get( + self._current_plugin.id + ) + if current_plugin_data and not current_plugin_data["passed"]: + current_plugin_data["passed"] = True if publish_plugins: for plugin in publish_plugins: - if plugin not in self._stored_plugins: - plugins_data.append(self._create_plugin_data_item(plugin)) + if plugin.id not in plugins_data_by_id: + plugins_data_by_id[plugin.id] = \ + self._create_plugin_data_item(plugin) reports = [] if self._create_discover_result is not None: @@ -328,7 +325,7 @@ class PublishReportMaker: ) return { - "plugins_data": plugins_data, + "plugins_data": list(plugins_data_by_id.values()), "instances": instances_details, "context": self._extract_context_data(self._current_context), "crashed_file_paths": crashed_file_paths, diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 018088e916..50d50f467a 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -20,7 +20,10 @@ from .widgets import ( RefreshButton, GoToCurrentButton, ) -from .views import DeselectableTreeView +from .views import ( + DeselectableTreeView, + TreeView, +) from .error_dialog import ErrorMessageBox from .lib import ( WrappedCallbackItem, @@ -43,6 +46,7 @@ from .overlay_messages import ( MessageOverlayObject, ) from .multiselection_combobox import MultiSelectionComboBox +from .thumbnail_paint_widget import ThumbnailPainterWidget __all__ = ( @@ -70,6 +74,7 @@ __all__ = ( "GoToCurrentButton", "DeselectableTreeView", + "TreeView", "ErrorMessageBox", @@ -90,4 +95,6 @@ __all__ = ( "MessageOverlayObject", "MultiSelectionComboBox", + + "ThumbnailPainterWidget", ) diff --git a/openpype/tools/utils/delegates.py b/openpype/tools/utils/delegates.py index c71c87f9b0..c51323e556 100644 --- a/openpype/tools/utils/delegates.py +++ b/openpype/tools/utils/delegates.py @@ -24,9 +24,12 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): lock = False def __init__(self, dbcon, *args, **kwargs): - self.dbcon = dbcon + self._dbcon = dbcon super(VersionDelegate, self).__init__(*args, **kwargs) + def get_project_name(self): + return self._dbcon.active_project() + def displayText(self, value, locale): if isinstance(value, HeroVersionType): return lib.format_version(value, True) @@ -120,7 +123,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): "Version is not integer" ) - project_name = self.dbcon.active_project() + project_name = self.get_project_name() # Add all available versions to the editor parent_id = item["version_document"]["parent"] version_docs = [ diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index 2ebc973a47..29c8c0ba8e 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -86,12 +86,22 @@ class HostToolsHelper: def get_loader_tool(self, parent): """Create, cache and return loader tool window.""" if self._loader_tool is None: - from openpype.tools.loader import LoaderWindow - host = registered_host() ILoadHost.validate_load_methods(host) + if AYON_SERVER_ENABLED: + from openpype.tools.ayon_loader.ui import LoaderWindow + from openpype.tools.ayon_loader import LoaderController - loader_window = LoaderWindow(parent=parent or self._parent) + controller = LoaderController(host=host) + loader_window = LoaderWindow( + controller=controller, + parent=parent or self._parent + ) + + else: + from openpype.tools.loader import LoaderWindow + + loader_window = LoaderWindow(parent=parent or self._parent) self._loader_tool = loader_window return self._loader_tool @@ -109,7 +119,7 @@ class HostToolsHelper: if use_context is None: use_context = False - if use_context: + if not AYON_SERVER_ENABLED and use_context: context = {"asset": get_current_asset_name()} loader_tool.set_context(context, refresh=True) else: @@ -161,14 +171,23 @@ class HostToolsHelper: def get_scene_inventory_tool(self, parent): """Create, cache and return scene inventory tool window.""" if self._scene_inventory_tool is None: - from openpype.tools.sceneinventory import SceneInventoryWindow - host = registered_host() ILoadHost.validate_load_methods(host) - scene_inventory_window = SceneInventoryWindow( - parent=parent or self._parent - ) + if AYON_SERVER_ENABLED: + from openpype.tools.ayon_sceneinventory.window import ( + SceneInventoryWindow) + + scene_inventory_window = SceneInventoryWindow( + parent=parent or self._parent + ) + + else: + from openpype.tools.sceneinventory import SceneInventoryWindow + + scene_inventory_window = SceneInventoryWindow( + parent=parent or self._parent + ) self._scene_inventory_tool = scene_inventory_window return self._scene_inventory_tool @@ -187,6 +206,9 @@ class HostToolsHelper: def get_library_loader_tool(self, parent): """Create, cache and return library loader tool window.""" + if AYON_SERVER_ENABLED: + return self.get_loader_tool(parent) + if self._library_loader_tool is None: from openpype.tools.libraryloader import LibraryLoaderWindow @@ -199,6 +221,9 @@ class HostToolsHelper: def show_library_loader(self, parent=None): """Loader tool for loading representations from library project.""" + if AYON_SERVER_ENABLED: + return self.show_loader(parent) + with qt_app_context(): library_loader_tool = self.get_library_loader_tool(parent) library_loader_tool.show() diff --git a/openpype/tools/utils/images/__init__.py b/openpype/tools/utils/images/__init__.py new file mode 100644 index 0000000000..3f437fcc8c --- /dev/null +++ b/openpype/tools/utils/images/__init__.py @@ -0,0 +1,56 @@ +import os +from qtpy import QtGui + +IMAGES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__))) + + +def get_image_path(filename): + """Get image path from './images'. + + Returns: + Union[str, None]: Path to image file or None if not found. + """ + + path = os.path.join(IMAGES_DIR, filename) + if os.path.exists(path): + return path + return None + + +def get_image(filename): + """Load image from './images' as QImage. + + Returns: + Union[QtGui.QImage, None]: QImage or None if not found. + """ + + path = get_image_path(filename) + if path: + return QtGui.QImage(path) + return None + + +def get_pixmap(filename): + """Load image from './images' as QPixmap. + + Returns: + Union[QtGui.QPixmap, None]: QPixmap or None if not found. + """ + + path = get_image_path(filename) + if path: + return QtGui.QPixmap(path) + return None + + +def get_icon(filename): + """Load image from './images' as QIcon. + + Returns: + Union[QtGui.QIcon, None]: QIcon or None if not found. + """ + + pix = get_pixmap(filename) + if pix: + return QtGui.QIcon(pix) + return None diff --git a/openpype/tools/utils/images/thumbnail.png b/openpype/tools/utils/images/thumbnail.png new file mode 100644 index 0000000000..adea862e5b Binary files /dev/null and b/openpype/tools/utils/images/thumbnail.png differ diff --git a/openpype/tools/utils/thumbnail_paint_widget.py b/openpype/tools/utils/thumbnail_paint_widget.py new file mode 100644 index 0000000000..130942aaf0 --- /dev/null +++ b/openpype/tools/utils/thumbnail_paint_widget.py @@ -0,0 +1,366 @@ +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.style import get_objected_colors + +from .lib import paint_image_with_color +from .images import get_image + + +class ThumbnailPainterWidget(QtWidgets.QWidget): + """Widget for painting of thumbnails. + + The widget use is to paint thumbnail or multiple thumbnails in a defined + area. Is not meant to show them in a grid but in overlay. + + It is expected that there is a logic that will provide thumbnails to + paint and set them using 'set_current_thumbnails' or + 'set_current_thumbnail_paths'. + """ + + width_ratio = 3.0 + height_ratio = 2.0 + border_width = 1 + max_thumbnails = 3 + offset_sep = 4 + checker_boxes_count = 20 + + def __init__(self, parent): + super(ThumbnailPainterWidget, self).__init__(parent) + + border_color = get_objected_colors("bg-buttons").get_qcolor() + thumbnail_bg_color = get_objected_colors("bg-view").get_qcolor() + + default_image = get_image("thumbnail.png") + default_pix = paint_image_with_color(default_image, border_color) + + self._border_color = border_color + self._thumbnail_bg_color = thumbnail_bg_color + self._default_pix = default_pix + + self._cached_pix = None + self._current_pixes = None + self._has_pixes = False + + self._bg_color = QtCore.Qt.transparent + self._use_checker = True + self._checker_color_1 = QtGui.QColor(89, 89, 89) + self._checker_color_2 = QtGui.QColor(188, 187, 187) + + def set_background_color(self, color): + self._bg_color = color + self.repaint() + + def set_use_checkboard(self, use_checker): + if self._use_checker is use_checker: + return + self._use_checker = use_checker + self.repaint() + + def set_checker_colors(self, color_1, color_2): + self._checker_color_1 = color_1 + self._checker_color_2 = color_2 + self.repaint() + + def set_border_color(self, color): + """Change border color. + + Args: + color (QtGui.QColor): Color to set. + """ + + self._border_color = color + self._default_pix = None + self.clear_cache() + + def set_thumbnail_bg_color(self, color): + """Change background color. + + Args: + color (QtGui.QColor): Color to set. + """ + + self._thumbnail_bg_color = color + self.clear_cache() + + @property + def has_pixes(self): + """Has set thumbnails. + + Returns: + bool: True if widget has thumbnails to paint. + """ + + return self._has_pixes + + def clear_cache(self): + """Clear cache of resized thumbnails and repaint widget.""" + + self._cached_pix = None + self.repaint() + + def set_current_thumbnails(self, pixmaps=None): + """Set current thumbnails. + + Args: + pixmaps (Optional[List[QtGui.QPixmap]]): List of pixmaps. + """ + + self._current_pixes = pixmaps or None + self._has_pixes = self._current_pixes is not None + self.clear_cache() + + def set_current_thumbnail_paths(self, thumbnail_paths=None): + """Set current thumbnails. + + Set current thumbnails using paths to a files. + + Args: + thumbnail_paths (Optional[List[str]]): List of paths to thumbnail + sources. + """ + + pixes = [] + if thumbnail_paths: + for thumbnail_path in thumbnail_paths: + pixes.append(QtGui.QPixmap(thumbnail_path)) + + self.set_current_thumbnails(pixes) + + def paintEvent(self, event): + if self._cached_pix is None: + self._cache_pix() + + painter = QtGui.QPainter() + painter.begin(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.drawPixmap(0, 0, self._cached_pix) + painter.end() + + def resizeEvent(self, event): + self._cached_pix = None + super(ThumbnailPainterWidget, self).resizeEvent(event) + + def _get_default_pix(self): + if self._default_pix is None: + default_image = get_image("thumbnail") + default_pix = paint_image_with_color( + default_image, self._border_color) + self._default_pix = default_pix + return self._default_pix + + def _paint_tile(self, width, height): + if not self._use_checker: + tile_pix = QtGui.QPixmap(width, width) + tile_pix.fill(self._bg_color) + return tile_pix + + checker_size = int(float(width) / self.checker_boxes_count) + if checker_size < 1: + checker_size = 1 + + checker_pix = QtGui.QPixmap(checker_size * 2, checker_size * 2) + checker_pix.fill(QtCore.Qt.transparent) + checker_painter = QtGui.QPainter() + checker_painter.begin(checker_pix) + checker_painter.setPen(QtCore.Qt.NoPen) + checker_painter.setBrush(self._checker_color_1) + checker_painter.drawRect( + 0, 0, checker_pix.width(), checker_pix.height() + ) + checker_painter.setBrush(self._checker_color_2) + checker_painter.drawRect( + 0, 0, checker_size, checker_size + ) + checker_painter.drawRect( + checker_size, checker_size, checker_size, checker_size + ) + checker_painter.end() + return checker_pix + + def _paint_default_pix(self, pix_width, pix_height): + full_border_width = 2 * self.border_width + width = pix_width - full_border_width + height = pix_height - full_border_width + if width > 100: + width = int(width * 0.6) + height = int(height * 0.6) + + scaled_pix = self._get_default_pix().scaled( + width, + height, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + pos_x = int( + (pix_width - scaled_pix.width()) / 2 + ) + pos_y = int( + (pix_height - scaled_pix.height()) / 2 + ) + new_pix = QtGui.QPixmap(pix_width, pix_height) + new_pix.fill(QtCore.Qt.transparent) + pix_painter = QtGui.QPainter() + pix_painter.begin(new_pix) + render_hints = ( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + render_hints |= QtGui.QPainter.HighQualityAntialiasing + + pix_painter.setRenderHints(render_hints) + pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) + pix_painter.end() + return new_pix + + def _draw_thumbnails(self, thumbnails, pix_width, pix_height): + full_border_width = 2 * self.border_width + + checker_pix = self._paint_tile(pix_width, pix_height) + + backgrounded_images = [] + for src_pix in thumbnails: + scaled_pix = src_pix.scaled( + pix_width - full_border_width, + pix_height - full_border_width, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + pos_x = int( + (pix_width - scaled_pix.width()) / 2 + ) + pos_y = int( + (pix_height - scaled_pix.height()) / 2 + ) + + new_pix = QtGui.QPixmap(pix_width, pix_height) + new_pix.fill(QtCore.Qt.transparent) + pix_painter = QtGui.QPainter() + pix_painter.begin(new_pix) + render_hints = ( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + render_hints |= QtGui.QPainter.HighQualityAntialiasing + pix_painter.setRenderHints(render_hints) + + tiled_rect = QtCore.QRectF( + pos_x, pos_y, scaled_pix.width(), scaled_pix.height() + ) + pix_painter.drawTiledPixmap( + tiled_rect, + checker_pix, + QtCore.QPointF(0.0, 0.0) + ) + pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) + pix_painter.end() + backgrounded_images.append(new_pix) + return backgrounded_images + + def _paint_dash_line(self, painter, rect): + pen = QtGui.QPen() + pen.setWidth(1) + pen.setBrush(QtCore.Qt.darkGray) + pen.setStyle(QtCore.Qt.DashLine) + + new_rect = rect.adjusted(1, 1, -1, -1) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.transparent) + # painter.drawRect(rect) + painter.drawRect(new_rect) + + def _cache_pix(self): + rect = self.rect() + rect_width = rect.width() + rect_height = rect.height() + + pix_x_offset = 0 + pix_y_offset = 0 + expected_height = int( + (rect_width / self.width_ratio) * self.height_ratio + ) + if expected_height > rect_height: + expected_height = rect_height + expected_width = int( + (rect_height / self.height_ratio) * self.width_ratio + ) + pix_x_offset = (rect_width - expected_width) / 2 + else: + expected_width = rect_width + pix_y_offset = (rect_height - expected_height) / 2 + + if self._current_pixes is None: + used_default_pix = True + pixes_to_draw = None + pixes_len = 1 + else: + used_default_pix = False + pixes_to_draw = self._current_pixes + if len(pixes_to_draw) > self.max_thumbnails: + pixes_to_draw = pixes_to_draw[:-self.max_thumbnails] + pixes_len = len(pixes_to_draw) + + width_offset, height_offset = self._get_pix_offset_size( + expected_width, expected_height, pixes_len + ) + pix_width = expected_width - width_offset + pix_height = expected_height - height_offset + + if used_default_pix: + thumbnail_images = [self._paint_default_pix(pix_width, pix_height)] + else: + thumbnail_images = self._draw_thumbnails( + pixes_to_draw, pix_width, pix_height + ) + + if pixes_len == 1: + width_offset_part = 0 + height_offset_part = 0 + else: + width_offset_part = int(float(width_offset) / (pixes_len - 1)) + height_offset_part = int(float(height_offset) / (pixes_len - 1)) + full_width_offset = width_offset + pix_x_offset + + final_pix = QtGui.QPixmap(rect_width, rect_height) + final_pix.fill(QtCore.Qt.transparent) + + bg_pen = QtGui.QPen() + bg_pen.setWidth(self.border_width) + bg_pen.setColor(self._border_color) + + final_painter = QtGui.QPainter() + final_painter.begin(final_pix) + render_hints = ( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + render_hints |= QtGui.QPainter.HighQualityAntialiasing + + final_painter.setRenderHints(render_hints) + + final_painter.setBrush(QtGui.QBrush(self._thumbnail_bg_color)) + final_painter.setPen(bg_pen) + final_painter.drawRect(rect) + + for idx, pix in enumerate(thumbnail_images): + x_offset = full_width_offset - (width_offset_part * idx) + y_offset = (height_offset_part * idx) + pix_y_offset + final_painter.drawPixmap(x_offset, y_offset, pix) + + # Draw drop enabled dashes + if used_default_pix: + self._paint_dash_line(final_painter, rect) + + final_painter.end() + + self._cached_pix = final_pix + + def _get_pix_offset_size(self, width, height, image_count): + if image_count == 1: + return 0, 0 + + part_width = width / self.offset_sep + part_height = height / self.offset_sep + return part_width, part_height diff --git a/openpype/tools/utils/views.py b/openpype/tools/utils/views.py index 01919d6745..596a47ede9 100644 --- a/openpype/tools/utils/views.py +++ b/openpype/tools/utils/views.py @@ -1,4 +1,6 @@ from openpype.resources import get_image_path +from openpype.tools.flickcharm import FlickCharm + from qtpy import QtWidgets, QtCore, QtGui, QtSvg @@ -57,3 +59,63 @@ class TreeViewSpinner(QtWidgets.QTreeView): self.paint_empty(event) else: super(TreeViewSpinner, self).paintEvent(event) + + +class TreeView(QtWidgets.QTreeView): + """Ultimate TreeView with flick charm and double click signals. + + Tree view have deselectable mode, which allows to deselect items by + clicking on item area without any items. + + Todos: + Add refresh animation. + """ + + double_clicked = QtCore.Signal(QtGui.QMouseEvent) + + def __init__(self, *args, **kwargs): + super(TreeView, self).__init__(*args, **kwargs) + self._deselectable = False + + self._flick_charm_activated = False + self._flick_charm = FlickCharm(parent=self) + self._before_flick_scroll_mode = None + + def is_deselectable(self): + return self._deselectable + + def set_deselectable(self, deselectable): + self._deselectable = deselectable + + deselectable = property(is_deselectable, set_deselectable) + + def mousePressEvent(self, event): + if self._deselectable: + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + super(TreeView, self).mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + self.double_clicked.emit(event) + + return super(TreeView, self).mouseDoubleClickEvent(event) + + def activate_flick_charm(self): + if self._flick_charm_activated: + return + self._flick_charm_activated = True + self._before_flick_scroll_mode = self.verticalScrollMode() + self._flick_charm.activateOn(self) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + + def deactivate_flick_charm(self): + if not self._flick_charm_activated: + return + self._flick_charm_activated = False + self._flick_charm.deactivateFrom(self) + if self._before_flick_scroll_mode is not None: + self.setVerticalScrollMode(self._before_flick_scroll_mode) diff --git a/openpype/version.py b/openpype/version.py index d1ebde3d04..b0a79162b2 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.1-nightly.2" +__version__ = "3.17.2" diff --git a/pyproject.toml b/pyproject.toml index d0b1ecf589..ad93b70c0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.17.0" # OpenPype +version = "3.17.2" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" diff --git a/server_addon/applications/server/applications.json b/server_addon/applications/server/applications.json index 8e5b28623e..60305cf1c4 100644 --- a/server_addon/applications/server/applications.json +++ b/server_addon/applications/server/applications.json @@ -109,6 +109,55 @@ } ] }, + "maya": { + "enabled": true, + "label": "Maya", + "icon": "{}/app_icons/maya.png", + "host_name": "maya", + "environment": "{\n \"MAYA_DISABLE_CLIC_IPM\": \"Yes\",\n \"MAYA_DISABLE_CIP\": \"Yes\",\n \"MAYA_DISABLE_CER\": \"Yes\",\n \"PYMEL_SKIP_MEL_INIT\": \"Yes\",\n \"LC_ALL\": \"C\"\n}\n", + "variants": [ + { + "name": "2024", + "label": "2024", + "executables": { + "windows": [ + "C:\\Program Files\\Autodesk\\Maya2024\\bin\\mayapy.exe" + ], + "darwin": [], + "linux": [ + "/usr/autodesk/maya2024/bin/mayapy" + ] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": "{\n \"MAYA_VERSION\": \"2024\"\n}", + "use_python_2": false + }, + { + "name": "2023", + "label": "2023", + "executables": { + "windows": [ + "C:\\Program Files\\Autodesk\\Maya2023\\bin\\mayapy.exe" + ], + "darwin": [], + "linux": [ + "/usr/autodesk/maya2023/bin/mayapy" + ] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": "{\n \"MAYA_VERSION\": \"2023\"\n}", + "use_python_2": false + } + ] + }, "adsk_3dsmax": { "enabled": true, "label": "3ds Max", @@ -237,6 +286,7 @@ }, { "name": "13-0", + "label": "13.0", "use_python_2": false, "executables": { "windows": [ @@ -319,6 +369,7 @@ }, { "name": "13-0", + "label": "13.0", "use_python_2": false, "executables": { "windows": [ @@ -405,6 +456,7 @@ }, { "name": "13-0", + "label": "13.0", "use_python_2": false, "executables": { "windows": [ @@ -491,6 +543,7 @@ }, { "name": "13-0", + "label": "13.0", "use_python_2": false, "executables": { "windows": [ @@ -577,6 +630,7 @@ }, { "name": "13-0", + "label": "13.0", "use_python_2": false, "executables": { "windows": [ diff --git a/server_addon/blender/server/settings/main.py b/server_addon/blender/server/settings/main.py index f6118d39cd..4476ea709b 100644 --- a/server_addon/blender/server/settings/main.py +++ b/server_addon/blender/server/settings/main.py @@ -9,6 +9,10 @@ from .publish_plugins import ( PublishPuginsModel, DEFAULT_BLENDER_PUBLISH_SETTINGS ) +from .render_settings import ( + RenderSettingsModel, + DEFAULT_RENDER_SETTINGS +) class UnitScaleSettingsModel(BaseSettingsModel): @@ -37,6 +41,8 @@ class BlenderSettings(BaseSettingsModel): default_factory=BlenderImageIOModel, title="Color Management (ImageIO)" ) + render_settings: RenderSettingsModel = Field( + default_factory=RenderSettingsModel, title="Render Settings") workfile_builder: TemplateWorkfileBaseOptions = Field( default_factory=TemplateWorkfileBaseOptions, title="Workfile Builder" @@ -55,6 +61,7 @@ DEFAULT_VALUES = { }, "set_frames_startup": True, "set_resolution_startup": True, + "render_settings": DEFAULT_RENDER_SETTINGS, "publish": DEFAULT_BLENDER_PUBLISH_SETTINGS, "workfile_builder": { "create_first_version": False, diff --git a/server_addon/blender/server/settings/publish_plugins.py b/server_addon/blender/server/settings/publish_plugins.py index 65dda78411..5e047b7013 100644 --- a/server_addon/blender/server/settings/publish_plugins.py +++ b/server_addon/blender/server/settings/publish_plugins.py @@ -26,6 +26,16 @@ class ValidatePluginModel(BaseSettingsModel): active: bool = Field(title="Active") +class ValidateFileSavedModel(BaseSettingsModel): + enabled: bool = Field(title="ValidateFileSaved") + optional: bool = Field(title="Optional") + active: bool = Field(title="Active") + exclude_families: list[str] = Field( + default_factory=list, + title="Exclude product types" + ) + + class ExtractBlendModel(BaseSettingsModel): enabled: bool = Field(True) optional: bool = Field(title="Optional") @@ -53,6 +63,21 @@ class PublishPuginsModel(BaseSettingsModel): title="Validate Camera Zero Keyframe", section="Validators" ) + ValidateFileSaved: ValidateFileSavedModel = Field( + default_factory=ValidateFileSavedModel, + title="Validate File Saved", + section="Validators" + ) + ValidateRenderCameraIsSet: ValidatePluginModel = Field( + default_factory=ValidatePluginModel, + title="Validate Render Camera Is Set", + section="Validators" + ) + ValidateDeadlinePublish: ValidatePluginModel = Field( + default_factory=ValidatePluginModel, + title="Validate Render Output for Deadline", + section="Validators" + ) ValidateMeshHasUvs: ValidatePluginModel = Field( default_factory=ValidatePluginModel, title="Validate Mesh Has Uvs" @@ -118,6 +143,22 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = { "optional": True, "active": True }, + "ValidateFileSaved": { + "enabled": True, + "optional": False, + "active": True, + "exclude_families": [] + }, + "ValidateRenderCameraIsSet": { + "enabled": True, + "optional": False, + "active": True + }, + "ValidateDeadlinePublish": { + "enabled": True, + "optional": False, + "active": True + }, "ValidateMeshHasUvs": { "enabled": True, "optional": True, diff --git a/server_addon/blender/server/settings/render_settings.py b/server_addon/blender/server/settings/render_settings.py new file mode 100644 index 0000000000..f62013982e --- /dev/null +++ b/server_addon/blender/server/settings/render_settings.py @@ -0,0 +1,109 @@ +"""Providing models and values for Blender Render Settings.""" +from pydantic import Field + +from ayon_server.settings import BaseSettingsModel + + +def aov_separators_enum(): + return [ + {"value": "dash", "label": "- (dash)"}, + {"value": "underscore", "label": "_ (underscore)"}, + {"value": "dot", "label": ". (dot)"} + ] + + +def image_format_enum(): + return [ + {"value": "exr", "label": "OpenEXR"}, + {"value": "bmp", "label": "BMP"}, + {"value": "rgb", "label": "Iris"}, + {"value": "png", "label": "PNG"}, + {"value": "jpg", "label": "JPEG"}, + {"value": "jp2", "label": "JPEG 2000"}, + {"value": "tga", "label": "Targa"}, + {"value": "tif", "label": "TIFF"}, + ] + + +def aov_list_enum(): + return [ + {"value": "empty", "label": "< none >"}, + {"value": "combined", "label": "Combined"}, + {"value": "z", "label": "Z"}, + {"value": "mist", "label": "Mist"}, + {"value": "normal", "label": "Normal"}, + {"value": "diffuse_light", "label": "Diffuse Light"}, + {"value": "diffuse_color", "label": "Diffuse Color"}, + {"value": "specular_light", "label": "Specular Light"}, + {"value": "specular_color", "label": "Specular Color"}, + {"value": "volume_light", "label": "Volume Light"}, + {"value": "emission", "label": "Emission"}, + {"value": "environment", "label": "Environment"}, + {"value": "shadow", "label": "Shadow"}, + {"value": "ao", "label": "Ambient Occlusion"}, + {"value": "denoising", "label": "Denoising"}, + {"value": "volume_direct", "label": "Direct Volumetric Scattering"}, + {"value": "volume_indirect", "label": "Indirect Volumetric Scattering"} + ] + + +def custom_passes_types_enum(): + return [ + {"value": "COLOR", "label": "Color"}, + {"value": "VALUE", "label": "Value"}, + ] + + +class CustomPassesModel(BaseSettingsModel): + """Custom Passes""" + _layout = "compact" + + attribute: str = Field("", title="Attribute name") + value: str = Field( + "COLOR", + title="Type", + enum_resolver=custom_passes_types_enum + ) + + +class RenderSettingsModel(BaseSettingsModel): + default_render_image_folder: str = Field( + title="Default Render Image Folder" + ) + aov_separator: str = Field( + "underscore", + title="AOV Separator Character", + enum_resolver=aov_separators_enum + ) + image_format: str = Field( + "exr", + title="Image Format", + enum_resolver=image_format_enum + ) + multilayer_exr: bool = Field( + title="Multilayer (EXR)" + ) + aov_list: list[str] = Field( + default_factory=list, + enum_resolver=aov_list_enum, + title="AOVs to create" + ) + custom_passes: list[CustomPassesModel] = Field( + default_factory=list, + title="Custom Passes", + description=( + "Add custom AOVs. They are added to the view layer and in the " + "Compositing Nodetree,\nbut they need to be added manually to " + "the Shader Nodetree." + ) + ) + + +DEFAULT_RENDER_SETTINGS = { + "default_render_image_folder": "renders/blender", + "aov_separator": "underscore", + "image_format": "exr", + "multilayer_exr": True, + "aov_list": [], + "custom_passes": [] +} diff --git a/server_addon/blender/server/version.py b/server_addon/blender/server/version.py index 485f44ac21..ae7362549b 100644 --- a/server_addon/blender/server/version.py +++ b/server_addon/blender/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.3" diff --git a/server_addon/deadline/server/settings/publish_plugins.py b/server_addon/deadline/server/settings/publish_plugins.py index 8d1b667345..8d48695a9c 100644 --- a/server_addon/deadline/server/settings/publish_plugins.py +++ b/server_addon/deadline/server/settings/publish_plugins.py @@ -124,6 +124,24 @@ class LimitGroupsSubmodel(BaseSettingsModel): ) +def fusion_deadline_plugin_enum(): + """Return a list of value/label dicts for the enumerator. + + Returning a list of dicts is used to allow for a custom label to be + displayed in the UI. + """ + return [ + { + "value": "Fusion", + "label": "Fusion" + }, + { + "value": "FusionCmd", + "label": "FusionCmd" + } + ] + + class FusionSubmitDeadlineModel(BaseSettingsModel): enabled: bool = Field(True, title="Enabled") optional: bool = Field(False, title="Optional") @@ -132,6 +150,9 @@ class FusionSubmitDeadlineModel(BaseSettingsModel): chunk_size: int = Field(10, title="Frame per Task") concurrent_tasks: int = Field(1, title="Number of concurrent tasks") group: str = Field("", title="Group Name") + plugin: str = Field("Fusion", + enum_resolver=fusion_deadline_plugin_enum, + title="Deadline Plugin") class NukeSubmitDeadlineModel(BaseSettingsModel): @@ -208,6 +229,16 @@ class CelactionSubmitDeadlineModel(BaseSettingsModel): ) +class BlenderSubmitDeadlineModel(BaseSettingsModel): + enabled: bool = Field(True) + optional: bool = Field(title="Optional") + active: bool = Field(title="Active") + use_published: bool = Field(title="Use Published scene") + priority: int = Field(title="Priority") + chunk_size: int = Field(title="Frame per Task") + group: str = Field("", title="Group Name") + + class AOVFilterSubmodel(BaseSettingsModel): _layout = "expanded" name: str = Field(title="Host") @@ -276,8 +307,10 @@ class PublishPluginsModel(BaseSettingsModel): title="After Effects to deadline") CelactionSubmitDeadline: CelactionSubmitDeadlineModel = Field( default_factory=CelactionSubmitDeadlineModel, - title="Celaction Submit Deadline" - ) + title="Celaction Submit Deadline") + BlenderSubmitDeadline: BlenderSubmitDeadlineModel = Field( + default_factory=BlenderSubmitDeadlineModel, + title="Blender Submit Deadline") ProcessSubmittedJobOnFarm: ProcessSubmittedJobOnFarmModel = Field( default_factory=ProcessSubmittedJobOnFarmModel, title="Process submitted job on farm.") @@ -384,6 +417,15 @@ DEFAULT_DEADLINE_PLUGINS_SETTINGS = { "deadline_chunk_size": 10, "deadline_job_delay": "00:00:00:00" }, + "BlenderSubmitDeadline": { + "enabled": True, + "optional": False, + "active": True, + "use_published": True, + "priority": 50, + "chunk_size": 10, + "group": "none" + }, "ProcessSubmittedJobOnFarm": { "enabled": True, "deadline_department": "", @@ -400,6 +442,12 @@ DEFAULT_DEADLINE_PLUGINS_SETTINGS = { ".*([Bb]eauty).*" ] }, + { + "name": "blender", + "value": [ + ".*([Bb]eauty).*" + ] + }, { "name": "aftereffects", "value": [ diff --git a/server_addon/deadline/server/version.py b/server_addon/deadline/server/version.py index 485f44ac21..b3f4756216 100644 --- a/server_addon/deadline/server/version.py +++ b/server_addon/deadline/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/server_addon/houdini/server/settings/general.py b/server_addon/houdini/server/settings/general.py new file mode 100644 index 0000000000..21cc4c452c --- /dev/null +++ b/server_addon/houdini/server/settings/general.py @@ -0,0 +1,45 @@ +from pydantic import Field +from ayon_server.settings import BaseSettingsModel + + +class HoudiniVarModel(BaseSettingsModel): + _layout = "expanded" + var: str = Field("", title="Var") + value: str = Field("", title="Value") + is_directory: bool = Field(False, title="Treat as directory") + + +class UpdateHoudiniVarcontextModel(BaseSettingsModel): + """Sync vars with context changes. + + If a value is treated as a directory on update + it will be ensured the folder exists. + """ + + enabled: bool = Field(title="Enabled") + # TODO this was dynamic dictionary '{var: path}' + houdini_vars: list[HoudiniVarModel] = Field( + default_factory=list, + title="Houdini Vars" + ) + + +class GeneralSettingsModel(BaseSettingsModel): + update_houdini_var_context: UpdateHoudiniVarcontextModel = Field( + default_factory=UpdateHoudiniVarcontextModel, + title="Update Houdini Vars on context change" + ) + + +DEFAULT_GENERAL_SETTINGS = { + "update_houdini_var_context": { + "enabled": True, + "houdini_vars": [ + { + "var": "JOB", + "value": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}", # noqa + "is_directory": True + } + ] + } +} diff --git a/server_addon/houdini/server/settings/main.py b/server_addon/houdini/server/settings/main.py index fdb6838f5c..0c2e160c87 100644 --- a/server_addon/houdini/server/settings/main.py +++ b/server_addon/houdini/server/settings/main.py @@ -4,7 +4,10 @@ from ayon_server.settings import ( MultiplatformPathModel, MultiplatformPathListModel, ) - +from .general import ( + GeneralSettingsModel, + DEFAULT_GENERAL_SETTINGS +) from .imageio import HoudiniImageIOModel from .publish_plugins import ( PublishPluginsModel, @@ -52,6 +55,10 @@ class ShelvesModel(BaseSettingsModel): class HoudiniSettings(BaseSettingsModel): + general: GeneralSettingsModel = Field( + default_factory=GeneralSettingsModel, + title="General" + ) imageio: HoudiniImageIOModel = Field( default_factory=HoudiniImageIOModel, title="Color Management (ImageIO)" @@ -73,6 +80,7 @@ class HoudiniSettings(BaseSettingsModel): DEFAULT_VALUES = { + "general": DEFAULT_GENERAL_SETTINGS, "shelves": [], "create": DEFAULT_HOUDINI_CREATE_SETTINGS, "publish": DEFAULT_HOUDINI_PUBLISH_SETTINGS diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index ae7362549b..bbab0242f6 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.1.3" +__version__ = "0.1.4" diff --git a/server_addon/maya/server/settings/publishers.py b/server_addon/maya/server/settings/publishers.py index bd7ccdf4d5..dd8d4a0a37 100644 --- a/server_addon/maya/server/settings/publishers.py +++ b/server_addon/maya/server/settings/publishers.py @@ -129,6 +129,10 @@ class CollectMayaRenderModel(BaseSettingsModel): ) +class CollectFbxAnimationModel(BaseSettingsModel): + enabled: bool = Field(title="Collect Fbx Animation") + + class CollectFbxCameraModel(BaseSettingsModel): enabled: bool = Field(title="CollectFbxCamera") @@ -364,6 +368,10 @@ class PublishersModel(BaseSettingsModel): title="Collect Render Layers", section="Collectors" ) + CollectFbxAnimation: CollectFbxAnimationModel = Field( + default_factory=CollectFbxAnimationModel, + title="Collect FBX Animation", + ) CollectFbxCamera: CollectFbxCameraModel = Field( default_factory=CollectFbxCameraModel, title="Collect Camera for FBX export", @@ -425,6 +433,10 @@ class PublishersModel(BaseSettingsModel): default_factory=ValidateRenderSettingsModel, title="Validate Render Settings" ) + ValidateResolution: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Resolution Setting" + ) ValidateCurrentRenderLayerIsRenderable: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Current Render Layer Has Renderable Camera" @@ -644,6 +656,10 @@ class PublishersModel(BaseSettingsModel): default_factory=BasicValidateModel, title="Validate Rig Controllers", ) + ValidateAnimatedReferenceRig: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Animated Reference Rig", + ) ValidateAnimationContent: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Animation Content", @@ -660,14 +676,34 @@ class PublishersModel(BaseSettingsModel): default_factory=BasicValidateModel, title="Validate Skeletal Mesh Top Node", ) + ValidateSkeletonRigContents: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Rig Contents" + ) + ValidateSkeletonRigControllers: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Rig Controllers" + ) ValidateSkinclusterDeformerSet: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Skincluster Deformer Relationships", ) + ValidateSkeletonRigOutputIds: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Rig Output Ids" + ) + ValidateSkeletonTopGroupHierarchy: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Top Group Hierarchy", + ) ValidateRigOutSetNodeIds: ValidateRigOutSetNodeIdsModel = Field( default_factory=ValidateRigOutSetNodeIdsModel, title="Validate Rig Out Set Node Ids", ) + ValidateSkeletonRigOutSetNodeIds: ValidateRigOutSetNodeIdsModel = Field( + default_factory=ValidateRigOutSetNodeIdsModel, + title="Validate Skeleton Rig Out Set Node Ids", + ) # Rig - END ValidateCameraAttributes: BasicValidateModel = Field( default_factory=BasicValidateModel, @@ -748,6 +784,9 @@ DEFAULT_PUBLISH_SETTINGS = { "CollectMayaRender": { "sync_workfile_version": False }, + "CollectFbxAnimation": { + "enabled": True + }, "CollectFbxCamera": { "enabled": False }, @@ -867,6 +906,11 @@ DEFAULT_PUBLISH_SETTINGS = { "redshift_render_attributes": [], "renderman_render_attributes": [] }, + "ValidateResolution": { + "enabled": True, + "optional": True, + "active": True + }, "ValidateCurrentRenderLayerIsRenderable": { "enabled": True, "optional": False, @@ -1143,6 +1187,11 @@ DEFAULT_PUBLISH_SETTINGS = { "optional": True, "active": True }, + "ValidateAnimatedReferenceRig": { + "enabled": True, + "optional": False, + "active": True + }, "ValidateAnimationContent": { "enabled": True, "optional": False, @@ -1163,6 +1212,16 @@ DEFAULT_PUBLISH_SETTINGS = { "optional": False, "active": True }, + "ValidateSkeletonRigContents": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateSkeletonRigControllers": { + "enabled": False, + "optional": True, + "active": True + }, "ValidateSkinclusterDeformerSet": { "enabled": True, "optional": False, @@ -1173,6 +1232,21 @@ DEFAULT_PUBLISH_SETTINGS = { "optional": False, "allow_history_only": False }, + "ValidateSkeletonRigOutSetNodeIds": { + "enabled": False, + "optional": False, + "allow_history_only": False + }, + "ValidateSkeletonRigOutputIds": { + "enabled": False, + "optional": True, + "active": True + }, + "ValidateSkeletonTopGroupHierarchy": { + "enabled": True, + "optional": True, + "active": True + }, "ValidateCameraAttributes": { "enabled": False, "optional": True, diff --git a/server_addon/maya/server/version.py b/server_addon/maya/server/version.py index e57ad00718..90ce344d3e 100644 --- a/server_addon/maya/server/version.py +++ b/server_addon/maya/server/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring addon version.""" -__version__ = "0.1.3" +__version__ = "0.1.5" diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py index c78685534f..692b2bd240 100644 --- a/server_addon/nuke/server/settings/publish_plugins.py +++ b/server_addon/nuke/server/settings/publish_plugins.py @@ -149,7 +149,7 @@ class ReformatNodesConfigModel(BaseSettingsModel): ) -class BakingStreamModel(BaseSettingsModel): +class IntermediateOutputModel(BaseSettingsModel): name: str = Field(title="Output name") filter: BakingStreamFilterModel = Field( title="Filter", default_factory=BakingStreamFilterModel) @@ -166,9 +166,21 @@ class BakingStreamModel(BaseSettingsModel): class ExtractReviewDataMovModel(BaseSettingsModel): + """[deprecated] use Extract Review Data Baking + Streams instead. + """ enabled: bool = Field(title="Enabled") viewer_lut_raw: bool = Field(title="Viewer lut raw") - outputs: list[BakingStreamModel] = Field( + outputs: list[IntermediateOutputModel] = Field( + default_factory=list, + title="Baking streams" + ) + + +class ExtractReviewIntermediatesModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + viewer_lut_raw: bool = Field(title="Viewer lut raw") + outputs: list[IntermediateOutputModel] = Field( default_factory=list, title="Baking streams" ) @@ -224,7 +236,7 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=CollectInstanceDataModel, section="Collectors" ) - ValidateCorrectAssetName: OptionalPluginModel = Field( + ValidateCorrectAssetContext: OptionalPluginModel = Field( title="Validate Correct Folder Name", default_factory=OptionalPluginModel, section="Validators" @@ -270,6 +282,10 @@ class PublishPuginsModel(BaseSettingsModel): title="Extract Review Data Mov", default_factory=ExtractReviewDataMovModel ) + ExtractReviewIntermediates: ExtractReviewIntermediatesModel = Field( + title="Extract Review Intermediates", + default_factory=ExtractReviewIntermediatesModel + ) ExtractSlateFrame: ExtractSlateFrameModel = Field( title="Extract Slate Frame", default_factory=ExtractSlateFrameModel @@ -292,7 +308,7 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { "write" ] }, - "ValidateCorrectAssetName": { + "ValidateCorrectAssetContext": { "enabled": True, "optional": True, "active": True @@ -465,6 +481,61 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { } ] }, + "ExtractReviewIntermediates": { + "enabled": True, + "viewer_lut_raw": False, + "outputs": [ + { + "name": "baking", + "filter": { + "task_types": [], + "product_types": [], + "product_names": [] + }, + "read_raw": False, + "viewer_process_override": "", + "bake_viewer_process": True, + "bake_viewer_input_process": True, + "reformat_nodes_config": { + "enabled": False, + "reposition_nodes": [ + { + "node_class": "Reformat", + "knobs": [ + { + "type": "text", + "name": "type", + "text": "to format" + }, + { + "type": "text", + "name": "format", + "text": "HD_1080" + }, + { + "type": "text", + "name": "filter", + "text": "Lanczos6" + }, + { + "type": "bool", + "name": "black_outside", + "boolean": True + }, + { + "type": "bool", + "name": "pbb", + "boolean": False + } + ] + } + ] + }, + "extension": "mov", + "add_custom_tags": [] + } + ] + }, "ExtractSlateFrame": { "viewer_lut_raw": False, "key_value_mapping": { diff --git a/server_addon/nuke/server/version.py b/server_addon/nuke/server/version.py index b3f4756216..ae7362549b 100644 --- a/server_addon/nuke/server/version.py +++ b/server_addon/nuke/server/version.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.1.3" diff --git a/tests/conftest.py b/tests/conftest.py index 4f7c17244b..6e82c9917d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,6 +29,11 @@ def pytest_addoption(parser): help="True - only setup test, do not run any tests" ) + parser.addoption( + "--mongo_url", action="store", default=None, + help="Provide url of the Mongo database." + ) + @pytest.fixture(scope="module") def test_data_folder(request): @@ -55,6 +60,11 @@ def setup_only(request): return request.config.getoption("--setup_only") +@pytest.fixture(scope="module") +def mongo_url(request): + return request.config.getoption("--mongo_url") + + @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): # execute all other hooks to obtain the report object diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya.py b/tests/integration/hosts/maya/test_deadline_publish_in_maya.py index c5bf526f52..9332177944 100644 --- a/tests/integration/hosts/maya/test_deadline_publish_in_maya.py +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya.py @@ -32,7 +32,7 @@ class TestDeadlinePublishInMaya(MayaDeadlinePublishTestClass): # keep empty to locate latest installed variant or explicit APP_VARIANT = "" - TIMEOUT = 120 # publish timeout + TIMEOUT = 180 # publish timeout def test_db_asserts(self, dbcon, publish_finished): """Host and input data dependent expected results in DB.""" diff --git a/tests/integration/hosts/nuke/test_publish_in_nuke.py b/tests/integration/hosts/nuke/test_publish_in_nuke.py index bfd84e4fd5..b7bb8716c0 100644 --- a/tests/integration/hosts/nuke/test_publish_in_nuke.py +++ b/tests/integration/hosts/nuke/test_publish_in_nuke.py @@ -68,7 +68,7 @@ class TestPublishInNuke(NukeLocalPublishTestClass): name="workfileTest_task")) failures.append( - DBAssert.count_of_types(dbcon, "representation", 4)) + DBAssert.count_of_types(dbcon, "representation", 3)) additional_args = {"context.subset": "workfileTest_task", "context.ext": "nk"} @@ -85,7 +85,7 @@ class TestPublishInNuke(NukeLocalPublishTestClass): additional_args = {"context.subset": "renderTest_taskMain", "name": "thumbnail"} failures.append( - DBAssert.count_of_types(dbcon, "representation", 1, + DBAssert.count_of_types(dbcon, "representation", 0, additional_args=additional_args)) additional_args = {"context.subset": "renderTest_taskMain", diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py index 2af4af02de..277b332e19 100644 --- a/tests/lib/testing_classes.py +++ b/tests/lib/testing_classes.py @@ -105,7 +105,7 @@ class ModuleUnitTest(BaseTest): yield path @pytest.fixture(scope="module") - def env_var(self, monkeypatch_session, download_test_data): + def env_var(self, monkeypatch_session, download_test_data, mongo_url): """Sets temporary env vars from json file.""" env_url = os.path.join(download_test_data, "input", "env_vars", "env_var.json") @@ -129,6 +129,9 @@ class ModuleUnitTest(BaseTest): monkeypatch_session.setenv(key, str(value)) #reset connection to openpype DB with new env var + if mongo_url: + monkeypatch_session.setenv("OPENPYPE_MONGO", mongo_url) + import openpype.settings.lib as sett_lib sett_lib._SETTINGS_HANDLER = None sett_lib._LOCAL_SETTINGS_HANDLER = None @@ -147,10 +150,9 @@ class ModuleUnitTest(BaseTest): @pytest.fixture(scope="module") def db_setup(self, download_test_data, env_var, monkeypatch_session, - request): + request, mongo_url): """Restore prepared MongoDB dumps into selected DB.""" backup_dir = os.path.join(download_test_data, "input", "dumps") - uri = os.environ.get("OPENPYPE_MONGO") db_handler = DBHandler(uri) db_handler.setup_from_dump(self.TEST_DB_NAME, backup_dir, diff --git a/tests/unit/openpype/hosts/photoshop/test_lib.py b/tests/unit/openpype/hosts/photoshop/test_lib.py new file mode 100644 index 0000000000..ad4feb42ae --- /dev/null +++ b/tests/unit/openpype/hosts/photoshop/test_lib.py @@ -0,0 +1,25 @@ +import pytest + +from openpype.hosts.photoshop.lib import clean_subset_name + +""" +Tests cleanup of unused layer placeholder ({layer}) from subset name. +Layer differentiation might be desired in subset name, but in some cases it +might be used (in `auto_image` - only single image without layer diff., +single image instance created without toggled use of subset name etc.) +""" + + +def test_no_layer_placeholder(): + clean_subset = clean_subset_name("imageMain") + assert "imageMain" == clean_subset + + +@pytest.mark.parametrize("subset_name", + ["imageMain{Layer}", + "imageMain_{layer}", # trailing _ + "image{Layer}Main", + "image{LAYER}Main"]) +def test_not_used_layer_placeholder(subset_name): + clean_subset = clean_subset_name(subset_name) + assert "imageMain" == clean_subset diff --git a/tests/unit/openpype/pipeline/test_colorspace.py b/tests/unit/openpype/pipeline/test_colorspace.py index ac35a28303..85faa8ff5d 100644 --- a/tests/unit/openpype/pipeline/test_colorspace.py +++ b/tests/unit/openpype/pipeline/test_colorspace.py @@ -26,8 +26,9 @@ class TestPipelineColorspace(TestPipeline): Example: cd to OpenPype repo root dir - poetry run python ./start.py runtests ../tests/unit/openpype/pipeline - """ + poetry run python ./start.py runtests /tests/unit/openpype/pipeline/test_colorspace.py + """ # noqa: E501 + TEST_FILES = [ ( "1csqimz8bbNcNgxtEXklLz6GRv91D3KgA", @@ -131,14 +132,14 @@ class TestPipelineColorspace(TestPipeline): path_1 = "renderCompMain_ACES2065-1.####.exr" expected_1 = "ACES2065-1" ret_1 = colorspace.parse_colorspace_from_filepath( - path_1, "nuke", "test_project", project_settings=project_settings + path_1, config_path=config_path_asset ) assert ret_1 == expected_1, f"Not matching colorspace {expected_1}" path_2 = "renderCompMain_BMDFilm_WideGamut_Gen5.mov" expected_2 = "BMDFilm WideGamut Gen5" ret_2 = colorspace.parse_colorspace_from_filepath( - path_2, "nuke", "test_project", project_settings=project_settings + path_2, config_path=config_path_asset ) assert ret_2 == expected_2, f"Not matching colorspace {expected_2}" @@ -184,5 +185,70 @@ class TestPipelineColorspace(TestPipeline): assert expected_hiero == hiero_file_rules, ( f"Not matching file rules {expected_hiero}") + def test_get_imageio_colorspace_from_filepath_p3(self, project_settings): + """Test Colorspace from filepath with python 3 compatibility mode + + Also test ocio v2 file rules + """ + nuke_filepath = "renderCompMain_baking_h264.mp4" + hiero_filepath = "prerenderCompMain.mp4" + + expected_nuke = "Camera Rec.709" + expected_hiero = "Gamma 2.2 Rec.709 - Texture" + + nuke_colorspace = colorspace.get_colorspace_name_from_filepath( + nuke_filepath, + "nuke", + "test_project", + project_settings=project_settings + ) + assert expected_nuke == nuke_colorspace, ( + f"Not matching colorspace {expected_nuke}") + + hiero_colorspace = colorspace.get_colorspace_name_from_filepath( + hiero_filepath, + "hiero", + "test_project", + project_settings=project_settings + ) + assert expected_hiero == hiero_colorspace, ( + f"Not matching colorspace {expected_hiero}") + + def test_get_imageio_colorspace_from_filepath_python2mode( + self, project_settings): + """Test Colorspace from filepath with python 2 compatibility mode + + Also test ocio v2 file rules + """ + nuke_filepath = "renderCompMain_baking_h264.mp4" + hiero_filepath = "prerenderCompMain.mp4" + + expected_nuke = "Camera Rec.709" + expected_hiero = "Gamma 2.2 Rec.709 - Texture" + + # switch to python 2 compatibility mode + colorspace.CachedData.has_compatible_ocio_package = False + + nuke_colorspace = colorspace.get_colorspace_name_from_filepath( + nuke_filepath, + "nuke", + "test_project", + project_settings=project_settings + ) + assert expected_nuke == nuke_colorspace, ( + f"Not matching colorspace {expected_nuke}") + + hiero_colorspace = colorspace.get_colorspace_name_from_filepath( + hiero_filepath, + "hiero", + "test_project", + project_settings=project_settings + ) + assert expected_hiero == hiero_colorspace, ( + f"Not matching colorspace {expected_hiero}") + + # return to python 3 compatibility mode + colorspace.CachedData.python3compatible = None + test_case = TestPipelineColorspace() diff --git a/website/docs/admin_hosts_houdini.md b/website/docs/admin_hosts_houdini.md index 64c54db591..18c390e07f 100644 --- a/website/docs/admin_hosts_houdini.md +++ b/website/docs/admin_hosts_houdini.md @@ -3,9 +3,36 @@ id: admin_hosts_houdini title: Houdini sidebar_label: Houdini --- +## General Settings +### Houdini Vars + +Allows admins to have a list of vars (e.g. JOB) with (dynamic) values that will be updated on context changes, e.g. when switching to another asset or task. + +Using template keys is supported but formatting keys capitalization variants is not, e.g. `{Asset}` and `{ASSET}` won't work + + +:::note +If `Treat as directory` toggle is activated, Openpype will consider the given value is a path of a folder. + +If the folder does not exist on the context change it will be created by this feature so that the path will always try to point to an existing folder. +::: + +Disabling `Update Houdini vars on context change` feature will leave all Houdini vars unmanaged and thus no context update changes will occur. + +> If `$JOB` is present in the Houdini var list and has an empty value, OpenPype will set its value to `$HIP` + + +:::note +For consistency reasons we always force all vars to be uppercase. +e.g. `myvar` will be `MYVAR` +::: + +![update-houdini-vars-context-change](assets/houdini/update-houdini-vars-context-change.png) + + ## Shelves Manager You can add your custom shelf set into Houdini by setting your shelf sets, shelves and tools in **Houdini -> Shelves Manager**. ![Custom menu definition](assets/houdini-admin_shelvesmanager.png) -The Shelf Set Path is used to load a .shelf file to generate your shelf set. If the path is specified, you don't have to set the shelves and tools. \ No newline at end of file +The Shelf Set Path is used to load a .shelf file to generate your shelf set. If the path is specified, you don't have to set the shelves and tools. diff --git a/website/docs/artist_publish.md b/website/docs/artist_publish.md index 321eb5c56a..b1be2e629e 100644 --- a/website/docs/artist_publish.md +++ b/website/docs/artist_publish.md @@ -33,39 +33,41 @@ The Instances are categorized into β€˜families’ based on what type of data the Following family definitions and requirements are OpenPype defaults and what we consider good industry practice, but most of the requirements can be easily altered to suit the studio or project needs. Here's a list of supported families -| Family | Comment | Example Subsets | -| ----------------------- | ------------------------------------------------ | ------------------------- | -| [Model](#model) | Cleaned geo without materials | main, proxy, broken | -| [Look](#look) | Package of shaders, assignments and textures | main, wet, dirty | -| [Rig](#rig) | Characters or props with animation controls | main, deform, sim | -| [Assembly](#assembly) | A complex model made from multiple other models. | main, deform, sim | -| [Layout](#layout) | Simple representation of the environment | main, | -| [Setdress](#setdress) | Environment containing only referenced assets | main, | -| [Camera](#camera) | May contain trackers or proxy geo | main, tracked, anim | -| [Animation](#animation) | Animation exported from a rig. | characterA, vehicleB | -| [Cache](#cache) | Arbitrary animated geometry or fx cache | rest, ROM , pose01 | -| MayaAscii | Maya publishes that don't fit other categories | | -| [Render](#render) | Rendered frames from CG or Comp | | -| RenderSetup | Scene render settings, AOVs and layers | | -| Plate | Ingested, transcode, conformed footage | raw, graded, imageplane | -| Write | Nuke write nodes for rendering | | -| Image | Any non-plate image to be used by artists | Reference, ConceptArt | -| LayeredImage | Software agnostic layered image with metadata | Reference, ConceptArt | -| Review | Reviewable video or image. | | -| Matchmove | Matchmoved camera, potentially with geometry | main | -| Workfile | Backup of the workfile with all its content | uses the task name | -| Nukenodes | Any collection of nuke nodes | maskSetup, usefulBackdrop | -| Yeticache | Cached out yeti fur setup | | -| YetiRig | Yeti groom ready to be applied to geometry cache | main, destroyed | -| VrayProxy | Vray proxy geometry for rendering | | -| VrayScene | Vray full scene export | | -| ArnodldStandin | All arnold .ass archives for rendering | main, wet, dirty | -| LUT | | | -| Nukenodes | | | -| Gizmo | | | -| Nukenodes | | | -| Harmony.template | | | -| Harmony.palette | | | +| Family | Comment | Example Subsets | +|-------------------------|-------------------------------------------------------| ------------------------- | +| [Model](#model) | Cleaned geo without materials | main, proxy, broken | +| [Look](#look) | Package of shaders, assignments and textures | main, wet, dirty | +| [Rig](#rig) | Characters or props with animation controls | main, deform, sim | +| [Assembly](#assembly) | A complex model made from multiple other models. | main, deform, sim | +| [Layout](#layout) | Simple representation of the environment | main, | +| [Setdress](#setdress) | Environment containing only referenced assets | main, | +| [Camera](#camera) | May contain trackers or proxy geo, only single camera | main, tracked, anim | +| | expected. | | +| [Animation](#animation) | Animation exported from a rig. | characterA, vehicleB | +| [Cache](#cache) | Arbitrary animated geometry or fx cache | rest, ROM , pose01 | +| MayaAscii | Maya publishes that don't fit other categories | | +| [Render](#render) | Rendered frames from CG or Comp | | +| RenderSetup | Scene render settings, AOVs and layers | | +| Plate | Ingested, transcode, conformed footage | raw, graded, imageplane | +| Write | Nuke write nodes for rendering | | +| Image | Any non-plate image to be used by artists | Reference, ConceptArt | +| LayeredImage | Software agnostic layered image with metadata | Reference, ConceptArt | +| Review | Reviewable video or image. | | +| Matchmove | Matchmoved camera, potentially with geometry, allows | main | +| | multiple cameras even with planes. | | +| Workfile | Backup of the workfile with all its content | uses the task name | +| Nukenodes | Any collection of nuke nodes | maskSetup, usefulBackdrop | +| Yeticache | Cached out yeti fur setup | | +| YetiRig | Yeti groom ready to be applied to geometry cache | main, destroyed | +| VrayProxy | Vray proxy geometry for rendering | | +| VrayScene | Vray full scene export | | +| ArnodldStandin | All arnold .ass archives for rendering | main, wet, dirty | +| LUT | | | +| Nukenodes | | | +| Gizmo | | | +| Nukenodes | | | +| Harmony.template | | | +| Harmony.palette | | | @@ -161,7 +163,7 @@ Example Representations: ### Animation Published result of an animation created with a rig. Animation can be extracted -as animation curves, cached out geometry or even fully animated rig with all the controllers. +as animation curves, cached out geometry or even fully animated rig with all the controllers. Animation cache is usually defined by a rigger in the rig file of a character or by FX TD in the effects rig, to ensure consistency of outputs. diff --git a/website/docs/assets/houdini/update-houdini-vars-context-change.png b/website/docs/assets/houdini/update-houdini-vars-context-change.png new file mode 100644 index 0000000000..74ac8d86c9 Binary files /dev/null and b/website/docs/assets/houdini/update-houdini-vars-context-change.png differ diff --git a/website/docs/project_settings/settings_project_global.md b/website/docs/project_settings/settings_project_global.md index e0481a8717..27aa60a464 100644 --- a/website/docs/project_settings/settings_project_global.md +++ b/website/docs/project_settings/settings_project_global.md @@ -189,7 +189,7 @@ A profile may generate multiple outputs from a single input. Each output must de - Profile filtering defines which group of output definitions is used but output definitions may require more specific filters on their own. - They may filter by subset name (regex can be used) or publish families. Publish families are more complex as are based on knowing code base. - Filtering by custom tags -> this is used for targeting to output definitions from other extractors using settings (at this moment only Nuke bake extractor can target using custom tags). - - Nuke extractor settings path: `project_settings/nuke/publish/ExtractReviewDataMov/outputs/baking/add_custom_tags` + - Nuke extractor settings path: `project_settings/nuke/publish/ExtractReviewIntermediates/outputs/baking/add_custom_tags` - Filtering by input length. Input may be video, sequence or single image. It is possible that `.mp4` should be created only when input is video or sequence and to create review `.png` when input is single frame. In some cases the output should be created even if it's single frame or multi frame input. diff --git a/website/docs/pype2/admin_presets_plugins.md b/website/docs/pype2/admin_presets_plugins.md index 6a057f4bb4..b5e8a3b8a8 100644 --- a/website/docs/pype2/admin_presets_plugins.md +++ b/website/docs/pype2/admin_presets_plugins.md @@ -534,8 +534,7 @@ Plugin responsible for generating thumbnails with colorspace controlled by Nuke. } ``` -### `ExtractReviewDataMov` - +### `ExtractReviewIntermediates` `viewer_lut_raw` **true** will publish the baked mov file without any colorspace conversion. It will be baked with the workfile workspace. This can happen in case the Viewer input process uses baked screen space luts. #### baking with controlled colorspace