Merge branch 'develop' into bugfix/extract_thumbnail_oiio

This commit is contained in:
Jakub Ježek 2023-11-20 17:02:59 +01:00 committed by GitHub
commit 1178fb09ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
69 changed files with 1474 additions and 1343 deletions

View file

@ -35,6 +35,8 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
- 3.17.7-nightly.1
- 3.17.6
- 3.17.6-nightly.3
- 3.17.6-nightly.2
- 3.17.6-nightly.1
@ -133,8 +135,6 @@ body:
- 3.15.2-nightly.4
- 3.15.2-nightly.3
- 3.15.2-nightly.2
- 3.15.2-nightly.1
- 3.15.1
validations:
required: true
- type: dropdown

View file

@ -1,6 +1,386 @@
# Changelog
## [3.17.6](https://github.com/ynput/OpenPype/tree/3.17.6)
[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.5...3.17.6)
### **🚀 Enhancements**
<details>
<summary>Testing: Validate Maya Logs <a href="https://github.com/ynput/OpenPype/pull/5775">#5775</a></summary>
This PR adds testing of the logs within Maya such as Python and Pyblish errors.The reason why we need to touch so many files outside of Maya is because of the pyblish errors below;
```
pyblish (ERROR) (line: 1371) pyblish.plugin:
Skipped: "collect_otio_frame_ranges" (No module named 'opentimelineio')
# Error: pyblish.plugin : Skipped: "collect_otio_frame_ranges" (No module named 'opentimelineio') #
pyblish (ERROR) (line: 1371) pyblish.plugin:
Skipped: "collect_otio_review" (No module named 'opentimelineio')
# Error: pyblish.plugin : Skipped: "collect_otio_review" (No module named 'opentimelineio') #
pyblish (ERROR) (line: 1371) pyblish.plugin:
Skipped: "collect_otio_subset_resources" (No module named 'opentimelineio')
# Error: pyblish.plugin : Skipped: "collect_otio_subset_resources" (No module named 'opentimelineio') #
pyblish (ERROR) (line: 1371) pyblish.plugin:
Skipped: "extract_otio_audio_tracks" (No module named 'opentimelineio')
# Error: pyblish.plugin : Skipped: "extract_otio_audio_tracks" (No module named 'opentimelineio') #
pyblish (ERROR) (line: 1371) pyblish.plugin:
Skipped: "extract_otio_file" (No module named 'opentimelineio')
# Error: pyblish.plugin : Skipped: "extract_otio_file" (No module named 'opentimelineio') #
pyblish (ERROR) (line: 1371) pyblish.plugin:
Skipped: "extract_otio_review" (No module named 'opentimelineio')
# Error: pyblish.plugin : Skipped: "extract_otio_review" (No module named 'opentimelineio') #
pyblish (ERROR) (line: 1371) pyblish.plugin:
Skipped: "extract_otio_trimming_video" (No module named 'opentimelineio')
# Error: pyblish.plugin : Skipped: "extract_otio_trimming_video" (No module named 'opentimelineio') #
pyblish (ERROR) (line: 1371) pyblish.plugin:
Skipped: "submit_blender_deadline" (No module named 'bpy')
# Error: pyblish.plugin : Skipped: "submit_blender_deadline" (No module named 'bpy') #
pyblish (ERROR) (line: 1371) pyblish.plugin:
Skipped: "submit_houdini_remote_publish" (No module named 'hou')
# Error: pyblish.plugin : Skipped: "submit_houdini_remote_publish" (No module named 'hou') #
pyblish (ERROR) (line: 1371) pyblish.plugin:
Skipped: "submit_houdini_render_deadline" (No module named 'hou')
# Error: pyblish.plugin : Skipped: "submit_houdini_render_deadline" (No module named 'hou') #
pyblish (ERROR) (line: 1371) pyblish.plugin:
Skipped: "submit_max_deadline" (No module named 'pymxs')
# Error: pyblish.plugin : Skipped: "submit_max_deadline" (No module named 'pymxs') #
pyblish (ERROR) (line: 1371) pyblish.plugin:
Skipped: "submit_nuke_deadline" (No module named 'nuke')
# Error: pyblish.plugin : Skipped: "submit_nuke_deadline" (No module named 'nuke') #
```
We also needed to `stdout` and `stderr` from the launched application to capture the output.Split from #5644.Dependent on #5734
___
</details>
<details>
<summary>Maya: Render Settings cleanup remove global `RENDER_ATTRS` <a href="https://github.com/ynput/OpenPype/pull/5801">#5801</a></summary>
Remove global `lib.RENDER_ATTRS` and implement a `RenderSettings.get_padding_attr(renderer)` method instead.
___
</details>
<details>
<summary>Testing: Ingest expected files and input workfile <a href="https://github.com/ynput/OpenPype/pull/5840">#5840</a></summary>
This ingests the Maya workfile from the Drive storage. Have changed the format to MayaAscii so its easier to see what changes are happening in a PR. This meant changing the expected files and database entries as well.
___
</details>
<details>
<summary>Chore: Create plugin auto-apply settings <a href="https://github.com/ynput/OpenPype/pull/5908">#5908</a></summary>
Create plugins can auto-apply settings.
___
</details>
<details>
<summary>Resolve: Add save current file button + "Save" shortcut when menu is active <a href="https://github.com/ynput/OpenPype/pull/5691">#5691</a></summary>
Adds a "Save current file" to the OpenPype menu.Also adds a "Save" shortcut key sequence (CTRL+S on Windows) to the button, so that clicking CTRL+S when the menu is active will save the current workfile. However this of course does not work if the menu does not receive the key press event (e.g. when Resolve UI is active instead)Resolves #5684
___
</details>
<details>
<summary>Reference USD file as maya native geometry <a href="https://github.com/ynput/OpenPype/pull/5781">#5781</a></summary>
Add MayaUsdReferenceLoader to reference USD as Maya native geometry using `mayaUSDImport` file translator.
___
</details>
<details>
<summary>Max: Bug fix on wrong aspect ratio and viewport not being maximized during context in review family <a href="https://github.com/ynput/OpenPype/pull/5839">#5839</a></summary>
This PR will fix the bug on wrong aspect ratio and viewport not being maximized when creating preview animationBesides, the support of tga image format and the options for AA quality are implemented in this PR
___
</details>
<details>
<summary>Blender: Incorporate blender "Collections" into Publish/Load <a href="https://github.com/ynput/OpenPype/pull/5841">#5841</a></summary>
Allow `blendScene` family to include collections.
___
</details>
<details>
<summary>Max: Allows user preset the setting of preview animation in OP/AYON Setting <a href="https://github.com/ynput/OpenPype/pull/5859">#5859</a></summary>
Allows user preset the setting of preview animation in OP/AYON Setting for review family.
- [x] Openpype
- [x] AYON
___
</details>
<details>
<summary>Publisher: Center publisher window on first show <a href="https://github.com/ynput/OpenPype/pull/5877">#5877</a></summary>
Move publisher window to center of a screen on first show.
___
</details>
<details>
<summary>Publisher: Instance context changes confirm works <a href="https://github.com/ynput/OpenPype/pull/5881">#5881</a></summary>
Confirmation of context changes in publisher on existing instances does not cause glitches.
___
</details>
<details>
<summary>AYON workfiles tools: Revisit workfiles tool <a href="https://github.com/ynput/OpenPype/pull/5897">#5897</a></summary>
Revisited workfiles tool for AYON mode to reuse common models and widgets.
___
</details>
<details>
<summary>Nuke: updated colorspace settings <a href="https://github.com/ynput/OpenPype/pull/5906">#5906</a></summary>
Updating nuke colorspace settings into more convenient way with usage of ocio config roles rather then particular colorspace names. This way we should not have troubles to switch between linear Rec709 or ACES configs without any additional settings changes.
___
</details>
<details>
<summary>Blender: Refactor to new publisher <a href="https://github.com/ynput/OpenPype/pull/5910">#5910</a></summary>
Refactor Blender integration to use the new publisher
___
</details>
<details>
<summary>Enhancement: Some publish logs cosmetics <a href="https://github.com/ynput/OpenPype/pull/5917">#5917</a></summary>
General logging message tweaks:
- Sort some lists of folder/filenames so they appear sorted in the logs
- Fix some grammar / typos
- In some cases provide slightly more information in a log
___
</details>
<details>
<summary>Blender: Better name of 'asset_name' function <a href="https://github.com/ynput/OpenPype/pull/5927">#5927</a></summary>
Renamed function `asset_name` to `prepare_scene_name`.
___
</details>
### **🐛 Bug fixes**
<details>
<summary>Maya: Bug fix the fbx animation export errored out when the skeletonAnim set is empty <a href="https://github.com/ynput/OpenPype/pull/5875">#5875</a></summary>
Resolve this bug discordIf the skeletonAnim SET is empty and fbx animation collect, the fbx animation extractor would skip the fbx extraction
___
</details>
<details>
<summary>Bugfix: fix few typos in houdini's and Maya's Ayon settings <a href="https://github.com/ynput/OpenPype/pull/5882">#5882</a></summary>
Fixing few typos
- [x] Maya unreal static mesh
- [x] Houdini static mesh
- [x] Houdini collect asset handles
___
</details>
<details>
<summary>Bugfix: Ayon Deadline env vars + error message on no executable found <a href="https://github.com/ynput/OpenPype/pull/5815">#5815</a></summary>
Fix some Ayon x Deadline issues as came up in this topic:
- missing Environment Variables issue explained here for `deadlinePlugin.RunProcess` for the AYON _extract environments_ call.
- wrong error formatting described here with a `;` between each character like this: `Ayon executable was not found in the semicolon separated list "C;:;/;P;r;o;g;r;a;m; ;F;i;l;e;s;/;Y;n;p;u;t;/;A;Y;O;N; ;1;.;0;.;0;-;b;e;t;a;.;5;/;a;y;o;n;_;c;o;n;s;o;l;e;.;e;x;e". The path to the render executable can be configured from the Plugin Configuration in the Deadline Monitor.`
___
</details>
<details>
<summary>AYON: Fix bundles access in settings <a href="https://github.com/ynput/OpenPype/pull/5856">#5856</a></summary>
Fixed access to bundles data in settings to define correct develop variant.
___
</details>
<details>
<summary>AYON 3dsMax settings: 'ValidateAttributes' settings converte only if available <a href="https://github.com/ynput/OpenPype/pull/5878">#5878</a></summary>
Convert `ValidateAttributes` settings only if are available in AYON settings.
___
</details>
<details>
<summary>AYON: Fix TrayPublisher editorial settings <a href="https://github.com/ynput/OpenPype/pull/5880">#5880</a></summary>
Fixing Traypublisher settings for adding task in simple editorial.
___
</details>
<details>
<summary>TrayPublisher: editorial frame range check not needed <a href="https://github.com/ynput/OpenPype/pull/5884">#5884</a></summary>
Validator for frame ranges is not needed during editorial publishing since entity data are not yet in database.
___
</details>
<details>
<summary>Update houdini license validator <a href="https://github.com/ynput/OpenPype/pull/5886">#5886</a></summary>
As reported in this community commentHoudini USD publishing is only restricted in Houdini apprentice.
___
</details>
<details>
<summary>Blender: Fix blend extraction and packed images <a href="https://github.com/ynput/OpenPype/pull/5888">#5888</a></summary>
Fixed a with blend extractor and packed images.
___
</details>
<details>
<summary>AYON: Initialize connection with all information <a href="https://github.com/ynput/OpenPype/pull/5890">#5890</a></summary>
Create global AYON api connection with all informations all the time.
___
</details>
<details>
<summary>AYON: Scene inventory tool without site sync <a href="https://github.com/ynput/OpenPype/pull/5896">#5896</a></summary>
Skip 'get_site_icons' if site sync addon is disabled.
___
</details>
<details>
<summary>Publish report tool: Fix PySide6 <a href="https://github.com/ynput/OpenPype/pull/5898">#5898</a></summary>
Use constants from classes instead of objects.
___
</details>
<details>
<summary>fusion: removing hardcoded template name for saver <a href="https://github.com/ynput/OpenPype/pull/5907">#5907</a></summary>
Fusion is not hardcoded for `render` anatomy template only anymore. This was blocking AYON deployment.
___
</details>
## [3.17.5](https://github.com/ynput/OpenPype/tree/3.17.5)

View file

@ -138,16 +138,22 @@ def _template_replacements_to_v3(template):
)
def _convert_template_item(template):
# Others won't have 'directory'
if "directory" not in template:
return
folder = _template_replacements_to_v3(template.pop("directory"))
template["folder"] = folder
template["file"] = _template_replacements_to_v3(template["file"])
template["path"] = "/".join(
(folder, template["file"])
)
def _convert_template_item(template_item):
for key, value in tuple(template_item.items()):
template_item[key] = _template_replacements_to_v3(value)
# Change 'directory' to 'folder'
if "directory" in template_item:
template_item["folder"] = template_item.pop("directory")
if (
"path" not in template_item
and "file" in template_item
and "folder" in template_item
):
template_item["path"] = "/".join(
(template_item["folder"], template_item["file"])
)
def _fill_template_category(templates, cat_templates, cat_key):
@ -212,10 +218,27 @@ def convert_v4_project_to_v3(project):
_convert_template_item(template)
new_others_templates[name] = template
staging_templates = templates.pop("staging", None)
# Key 'staging_directories' is legacy key that changed
# to 'staging_dir'
_legacy_staging_templates = templates.pop("staging_directories", None)
if staging_templates is None:
staging_templates = _legacy_staging_templates
if staging_templates is None:
staging_templates = {}
# Prefix all staging template names with 'staging_' prefix
# and add them to 'others'
for name, template in staging_templates.items():
_convert_template_item(template)
new_name = "staging_{}".format(name)
new_others_templates[new_name] = template
for key in (
"work",
"publish",
"hero"
"hero",
):
cat_templates = templates.pop(key)
_fill_template_category(templates, cat_templates, key)

View file

@ -1,4 +1,7 @@
import collections
import json
import six
from ayon_api.graphql import GraphQlQuery, FIELD_VALUE, fields_to_dict
from .constants import DEFAULT_FOLDER_FIELDS
@ -84,12 +87,12 @@ def get_folders_with_tasks(
for folder. All possible folder fields are returned if 'None'
is passed.
Returns:
List[Dict[str, Any]]: Queried folder entities.
Yields:
Dict[str, Any]: Queried folder entities.
"""
if not project_name:
return []
return
filters = {
"projectName": project_name
@ -97,25 +100,25 @@ def get_folders_with_tasks(
if folder_ids is not None:
folder_ids = set(folder_ids)
if not folder_ids:
return []
return
filters["folderIds"] = list(folder_ids)
if folder_paths is not None:
folder_paths = set(folder_paths)
if not folder_paths:
return []
return
filters["folderPaths"] = list(folder_paths)
if folder_names is not None:
folder_names = set(folder_names)
if not folder_names:
return []
return
filters["folderNames"] = list(folder_names)
if parent_ids is not None:
parent_ids = set(parent_ids)
if not parent_ids:
return []
return
if None in parent_ids:
# Replace 'None' with '"root"' which is used during GraphQl
# query for parent ids filter for folders without folder
@ -147,10 +150,10 @@ def get_folders_with_tasks(
parsed_data = query.query(con)
folders = parsed_data["project"]["folders"]
if active is None:
return folders
return [
folder
for folder in folders
if folder["active"] is active
]
for folder in folders:
if active is not None and folder["active"] is not active:
continue
folder_data = folder.get("data")
if isinstance(folder_data, six.string_types):
folder["data"] = json.loads(folder_data)
yield folder

View file

@ -28,7 +28,7 @@ from .lib import imprint
VALID_EXTENSIONS = [".blend", ".json", ".abc", ".fbx"]
def asset_name(
def prepare_scene_name(
asset: str, subset: str, namespace: Optional[str] = None
) -> str:
"""Return a consistent name for an asset."""
@ -225,7 +225,7 @@ class BaseCreator(Creator):
bpy.context.scene.collection.children.link(instances)
# Create asset group
name = asset_name(instance_data["asset"], subset_name)
name = prepare_scene_name(instance_data["asset"], subset_name)
if self.create_as_asset_group:
# Create instance as empty
instance_node = bpy.data.objects.new(name=name, object_data=None)
@ -298,7 +298,9 @@ class BaseCreator(Creator):
"subset" in changes.changed_keys
or "asset" in changes.changed_keys
):
name = asset_name(asset=data["asset"], subset=data["subset"])
name = prepare_scene_name(
asset=data["asset"], subset=data["subset"]
)
node.name = name
imprint(node, data)
@ -454,7 +456,7 @@ class AssetLoader(LoaderPlugin):
asset, subset
)
namespace = namespace or f"{asset}_{unique_number}"
name = name or asset_name(
name = name or prepare_scene_name(
asset, subset, unique_number
)
@ -483,7 +485,9 @@ class AssetLoader(LoaderPlugin):
# asset = context["asset"]["name"]
# subset = context["subset"]["name"]
# instance_name = asset_name(asset, subset, unique_number) + '_CON'
# instance_name = prepare_scene_name(
# asset, subset, unique_number
# ) + '_CON'
# return self._get_instance_collection(instance_name, nodes)

View file

@ -22,7 +22,7 @@ class CreateAction(plugin.BaseCreator):
)
# Get instance name
name = plugin.asset_name(instance_data["asset"], subset_name)
name = plugin.prepare_scene_name(instance_data["asset"], subset_name)
if pre_create_data.get("use_selection"):
for obj in lib.get_selection():

View file

@ -7,7 +7,7 @@ def append_workfile(context, fname, do_import):
asset = context['asset']['name']
subset = context['subset']['name']
group_name = plugin.asset_name(asset, subset)
group_name = plugin.prepare_scene_name(asset, subset)
# We need to preserve the original names of the scenes, otherwise,
# if there are duplicate names in the current workfile, the imported

View file

@ -137,9 +137,9 @@ class CacheModelLoader(plugin.AssetLoader):
asset = context["asset"]["name"]
subset = context["subset"]["name"]
asset_name = plugin.asset_name(asset, subset)
asset_name = plugin.prepare_scene_name(asset, subset)
unique_number = plugin.get_unique_number(asset, subset)
group_name = plugin.asset_name(asset, subset, unique_number)
group_name = plugin.prepare_scene_name(asset, subset, unique_number)
namespace = namespace or f"{asset}_{unique_number}"
containers = bpy.data.collections.get(AVALON_CONTAINERS)

View file

@ -7,7 +7,7 @@ from typing import Dict, List, Optional
import bpy
from openpype.pipeline import get_representation_path
import openpype.hosts.blender.api.plugin
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import (
containerise_existing,
AVALON_PROPERTY,
@ -16,7 +16,7 @@ from openpype.hosts.blender.api.pipeline import (
logger = logging.getLogger("openpype").getChild("blender").getChild("load_action")
class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader):
class BlendActionLoader(plugin.AssetLoader):
"""Load action from a .blend file.
Warning:
@ -46,8 +46,8 @@ class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader):
libpath = self.filepath_from_context(context)
asset = context["asset"]["name"]
subset = context["subset"]["name"]
lib_container = openpype.hosts.blender.api.plugin.asset_name(asset, subset)
container_name = openpype.hosts.blender.api.plugin.asset_name(
lib_container = plugin.prepare_scene_name(asset, subset)
container_name = plugin.prepare_scene_name(
asset, subset, namespace
)
@ -152,7 +152,7 @@ class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader):
assert libpath.is_file(), (
f"The file doesn't exist: {libpath}"
)
assert extension in openpype.hosts.blender.api.plugin.VALID_EXTENSIONS, (
assert extension in plugin.VALID_EXTENSIONS, (
f"Unsupported file: {libpath}"
)

View file

@ -42,9 +42,9 @@ class AudioLoader(plugin.AssetLoader):
asset = context["asset"]["name"]
subset = context["subset"]["name"]
asset_name = plugin.asset_name(asset, subset)
asset_name = plugin.prepare_scene_name(asset, subset)
unique_number = plugin.get_unique_number(asset, subset)
group_name = plugin.asset_name(asset, subset, unique_number)
group_name = plugin.prepare_scene_name(asset, subset, unique_number)
namespace = namespace or f"{asset}_{unique_number}"
avalon_container = bpy.data.collections.get(AVALON_CONTAINERS)

View file

@ -133,9 +133,9 @@ class BlendLoader(plugin.AssetLoader):
representation = str(context["representation"]["_id"])
asset_name = plugin.asset_name(asset, subset)
asset_name = plugin.prepare_scene_name(asset, subset)
unique_number = plugin.get_unique_number(asset, subset)
group_name = plugin.asset_name(asset, subset, unique_number)
group_name = plugin.prepare_scene_name(asset, subset, unique_number)
namespace = namespace or f"{asset}_{unique_number}"
avalon_container = bpy.data.collections.get(AVALON_CONTAINERS)

View file

@ -85,9 +85,9 @@ class BlendSceneLoader(plugin.AssetLoader):
except ValueError:
family = "model"
asset_name = plugin.asset_name(asset, subset)
asset_name = plugin.prepare_scene_name(asset, subset)
unique_number = plugin.get_unique_number(asset, subset)
group_name = plugin.asset_name(asset, subset, unique_number)
group_name = plugin.prepare_scene_name(asset, subset, unique_number)
namespace = namespace or f"{asset}_{unique_number}"
avalon_container = bpy.data.collections.get(AVALON_CONTAINERS)

View file

@ -87,9 +87,9 @@ class AbcCameraLoader(plugin.AssetLoader):
asset = context["asset"]["name"]
subset = context["subset"]["name"]
asset_name = plugin.asset_name(asset, subset)
asset_name = plugin.prepare_scene_name(asset, subset)
unique_number = plugin.get_unique_number(asset, subset)
group_name = plugin.asset_name(asset, subset, unique_number)
group_name = plugin.prepare_scene_name(asset, subset, unique_number)
namespace = namespace or f"{asset}_{unique_number}"
avalon_container = bpy.data.collections.get(AVALON_CONTAINERS)

View file

@ -90,9 +90,9 @@ class FbxCameraLoader(plugin.AssetLoader):
asset = context["asset"]["name"]
subset = context["subset"]["name"]
asset_name = plugin.asset_name(asset, subset)
asset_name = plugin.prepare_scene_name(asset, subset)
unique_number = plugin.get_unique_number(asset, subset)
group_name = plugin.asset_name(asset, subset, unique_number)
group_name = plugin.prepare_scene_name(asset, subset, unique_number)
namespace = namespace or f"{asset}_{unique_number}"
avalon_container = bpy.data.collections.get(AVALON_CONTAINERS)

View file

@ -134,9 +134,9 @@ class FbxModelLoader(plugin.AssetLoader):
asset = context["asset"]["name"]
subset = context["subset"]["name"]
asset_name = plugin.asset_name(asset, subset)
asset_name = plugin.prepare_scene_name(asset, subset)
unique_number = plugin.get_unique_number(asset, subset)
group_name = plugin.asset_name(asset, subset, unique_number)
group_name = plugin.prepare_scene_name(asset, subset, unique_number)
namespace = namespace or f"{asset}_{unique_number}"
avalon_container = bpy.data.collections.get(AVALON_CONTAINERS)

View file

@ -149,9 +149,9 @@ class JsonLayoutLoader(plugin.AssetLoader):
asset = context["asset"]["name"]
subset = context["subset"]["name"]
asset_name = plugin.asset_name(asset, subset)
asset_name = plugin.prepare_scene_name(asset, subset)
unique_number = plugin.get_unique_number(asset, subset)
group_name = plugin.asset_name(asset, subset, unique_number)
group_name = plugin.prepare_scene_name(asset, subset, unique_number)
namespace = namespace or f"{asset}_{unique_number}"
avalon_container = bpy.data.collections.get(AVALON_CONTAINERS)

View file

@ -96,14 +96,14 @@ class BlendLookLoader(plugin.AssetLoader):
asset = context["asset"]["name"]
subset = context["subset"]["name"]
lib_container = plugin.asset_name(
lib_container = plugin.prepare_scene_name(
asset, subset
)
unique_number = plugin.get_unique_number(
asset, subset
)
namespace = namespace or f"{asset}_{unique_number}"
container_name = plugin.asset_name(
container_name = plugin.prepare_scene_name(
asset, subset, unique_number
)

View file

@ -149,9 +149,7 @@ class CreateSaver(NewCreator):
# get frame padding from anatomy templates
anatomy = Anatomy()
frame_padding = int(
anatomy.templates["render"].get("frame_padding", 4)
)
frame_padding = anatomy.templates["frame_padding"]
# Subset change detected
workdir = os.path.normpath(legacy_io.Session["AVALON_WORKDIR"])

View file

@ -359,8 +359,6 @@ def reset_colorspace():
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()

View file

@ -204,6 +204,8 @@ class MaxCreator(Creator, MaxCreatorBase):
def create(self, subset_name, instance_data, pre_create_data):
if pre_create_data.get("use_selection"):
self.selected_nodes = rt.GetCurrentSelection()
if rt.getNodeByName(subset_name):
raise CreatorError(f"'{subset_name}' is already created..")
instance_node = self.create_instance_node(subset_name)
instance_data["instance_node"] = instance_node.name
@ -246,14 +248,25 @@ class MaxCreator(Creator, MaxCreatorBase):
def update_instances(self, update_list):
for created_inst, changes in update_list:
instance_node = created_inst.get("instance_node")
new_values = {
key: changes[key].new_value
for key in changes.changed_keys
}
subset = new_values.get("subset", "")
if subset and instance_node != subset:
node = rt.getNodeByName(instance_node)
new_subset_name = new_values["subset"]
if rt.getNodeByName(new_subset_name):
raise CreatorError(
"The subset '{}' already exists.".format(
new_subset_name))
instance_node = new_subset_name
created_inst["instance_node"] = instance_node
node.name = instance_node
imprint(
instance_node,
new_values,
created_inst.data_to_store(),
)
def remove_instances(self, instances):

View file

@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
"""Validator for Loaded Plugin."""
import os
import pyblish.api
from pymxs import runtime as rt
from openpype.pipeline.publish import (
RepairAction,
OptionalPyblishPluginMixin,
PublishValidationError
)
from openpype.hosts.max.api.lib import get_plugins
class ValidateLoadedPlugin(OptionalPyblishPluginMixin,
pyblish.api.InstancePlugin):
"""Validates if the specific plugin is loaded in 3ds max.
Studio Admin(s) can add the plugins they want to check in validation
via studio defined project settings
"""
order = pyblish.api.ValidatorOrder
hosts = ["max"]
label = "Validate Loaded Plugins"
optional = True
actions = [RepairAction]
family_plugins_mapping = {}
@classmethod
def get_invalid(cls, instance):
"""Plugin entry point."""
family_plugins_mapping = cls.family_plugins_mapping
if not family_plugins_mapping:
return
invalid = []
# Find all plug-in requirements for current instance
instance_families = {instance.data["family"]}
instance_families.update(instance.data.get("families", []))
cls.log.debug("Checking plug-in validation "
f"for instance families: {instance_families}")
all_required_plugins = set()
for mapping in family_plugins_mapping:
# Check for matching families
if not mapping:
return
match_families = {fam.strip() for fam in mapping["families"]}
has_match = "*" in match_families or match_families.intersection(
instance_families)
if not has_match:
continue
cls.log.debug(
f"Found plug-in family requirements: {match_families}")
required_plugins = [
# match lowercase and format with os.environ to allow
# plugin names defined by max version, e.g. {3DSMAX_VERSION}
plugin.format(**os.environ).lower()
for plugin in mapping["plugins"]
# ignore empty fields in settings
if plugin.strip()
]
all_required_plugins.update(required_plugins)
if not all_required_plugins:
# Instance has no plug-in requirements
return
# get all DLL loaded plugins in Max and their plugin index
available_plugins = {
plugin_name.lower(): index for index, plugin_name in enumerate(
get_plugins())
}
# validate the required plug-ins
for plugin in sorted(all_required_plugins):
plugin_index = available_plugins.get(plugin)
if plugin_index is None:
debug_msg = (
f"Plugin {plugin} does not exist"
" in 3dsMax Plugin List."
)
invalid.append((plugin, debug_msg))
continue
if not rt.pluginManager.isPluginDllLoaded(plugin_index):
debug_msg = f"Plugin {plugin} not loaded."
invalid.append((plugin, debug_msg))
return invalid
def process(self, instance):
if not self.is_active(instance.data):
self.log.debug("Skipping Validate Loaded Plugin...")
return
invalid = self.get_invalid(instance)
if invalid:
bullet_point_invalid_statement = "\n".join(
"- {}".format(message) for _, message in invalid
)
report = (
"Required plugins are not loaded.\n\n"
f"{bullet_point_invalid_statement}\n\n"
"You can use repair action to load the plugin."
)
raise PublishValidationError(
report, title="Missing Required Plugins")
@classmethod
def repair(cls, instance):
# get all DLL loaded plugins in Max and their plugin index
invalid = cls.get_invalid(instance)
if not invalid:
return
# get all DLL loaded plugins in Max and their plugin index
available_plugins = {
plugin_name.lower(): index for index, plugin_name in enumerate(
get_plugins())
}
for invalid_plugin, _ in invalid:
plugin_index = available_plugins.get(invalid_plugin)
if plugin_index is None:
cls.log.warning(
f"Can't enable missing plugin: {invalid_plugin}")
continue
if not rt.pluginManager.isPluginDllLoaded(plugin_index):
rt.pluginManager.loadPluginDll(plugin_index)

View file

@ -1,49 +0,0 @@
# -*- coding: utf-8 -*-
"""Validator for USD plugin."""
from pyblish.api import InstancePlugin, ValidatorOrder
from pymxs import runtime as rt
from openpype.pipeline import (
OptionalPyblishPluginMixin,
PublishValidationError
)
def get_plugins() -> list:
"""Get plugin list from 3ds max."""
manager = rt.PluginManager
count = manager.pluginDllCount
plugin_info_list = []
for p in range(1, count + 1):
plugin_info = manager.pluginDllName(p)
plugin_info_list.append(plugin_info)
return plugin_info_list
class ValidateUSDPlugin(OptionalPyblishPluginMixin,
InstancePlugin):
"""Validates if USD plugin is installed or loaded in 3ds max."""
order = ValidatorOrder - 0.01
families = ["model"]
hosts = ["max"]
label = "Validate USD Plugin loaded"
optional = True
def process(self, instance):
"""Plugin entry point."""
for sc in ValidateUSDPlugin.__subclasses__():
self.log.info(sc)
if not self.is_active(instance.data):
return
plugin_info = get_plugins()
usd_import = "usdimport.dli"
if usd_import not in plugin_info:
raise PublishValidationError(f"USD Plugin {usd_import} not found")
usd_export = "usdexport.dle"
if usd_export not in plugin_info:
raise PublishValidationError(f"USD Plugin {usd_export} not found")

View file

@ -120,7 +120,7 @@ def deprecated(new_destination):
class Context:
main_window = None
context_label = None
context_action_item = None
project_name = os.getenv("AVALON_PROJECT")
# Workfile related code
workfiles_launched = False

View file

@ -236,9 +236,13 @@ def _install_menu():
if not ASSIST:
label = get_context_label()
Context.context_label = label
context_action = menu.addCommand(label)
context_action.setEnabled(False)
context_action_item = menu.addCommand("Context")
context_action_item.setEnabled(False)
Context.context_action_item = context_action_item
context_action = context_action_item.action()
context_action.setText(label)
# add separator after context label
menu.addSeparator()
@ -348,26 +352,21 @@ def _install_menu():
def change_context_label():
menubar = nuke.menu("Nuke")
menu = menubar.findItem(MENU_LABEL)
if ASSIST:
return
label = get_context_label()
context_action_item = Context.context_action_item
if context_action_item is None:
return
context_action = context_action_item.action()
rm_item = [
(i, item) for i, item in enumerate(menu.items())
if Context.context_label in item.name()
][0]
old_label = context_action.text()
new_label = get_context_label()
menu.removeItem(rm_item[1].name())
context_action = menu.addCommand(
label,
index=(rm_item[0])
)
context_action.setEnabled(False)
context_action.setText(new_label)
log.info("Task label changed from `{}` to `{}`".format(
Context.context_label, label))
old_label, new_label))
def add_shortcuts_from_presets():

View file

@ -22,9 +22,9 @@ class CollectAutoImage(pyblish.api.ContextPlugin):
self.log.debug("Auto image instance found, won't create new")
return
project_name = context.data["anatomyData"]["project"]["name"]
project_name = context.data["projectName"]
proj_settings = context.data["project_settings"]
task_name = context.data["anatomyData"]["task"]["name"]
task_name = context.data["task"]
host_name = context.data["hostName"]
asset_doc = context.data["assetEntity"]
asset_name = asset_doc["name"]

View file

@ -60,9 +60,9 @@ class CollectAutoReview(pyblish.api.ContextPlugin):
variant = (context.data.get("variant") or
auto_creator["default_variant"])
project_name = context.data["anatomyData"]["project"]["name"]
project_name = context.data["projectName"]
proj_settings = context.data["project_settings"]
task_name = context.data["anatomyData"]["task"]["name"]
task_name = context.data["task"]
host_name = context.data["hostName"]
asset_doc = context.data["assetEntity"]
asset_name = asset_doc["name"]

View file

@ -51,7 +51,7 @@ class CollectAutoWorkfile(pyblish.api.ContextPlugin):
self.log.debug("Workfile instance disabled")
return
project_name = context.data["anatomyData"]["project"]["name"]
project_name = context.data["projectName"]
proj_settings = context.data["project_settings"]
auto_creator = proj_settings.get(
"photoshop", {}).get(
@ -66,7 +66,7 @@ class CollectAutoWorkfile(pyblish.api.ContextPlugin):
variant = (context.data.get("variant") or
auto_creator["default_variant"])
task_name = context.data["anatomyData"]["task"]["name"]
task_name = context.data["task"]
host_name = context.data["hostName"]
asset_doc = context.data["assetEntity"]
asset_name = asset_doc["name"]

View file

@ -170,7 +170,8 @@ class SubstanceHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
parent = substance_painter.ui.get_main_window()
menu = QtWidgets.QMenu("OpenPype")
tab_menu_label = os.environ.get("AVALON_LABEL") or "AYON"
menu = QtWidgets.QMenu(tab_menu_label)
action = menu.addAction("Create...")
action.triggered.connect(

View file

@ -708,6 +708,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin,
"""
project_name = context.data["projectName"]
host_name = context.data["hostName"]
if not version:
version = get_last_version_by_subset_name(
project_name,
@ -719,7 +720,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin,
else:
version = get_versioning_start(
project_name,
template_data["app"],
host_name,
task_name=template_data["task"]["name"],
task_type=template_data["task"]["type"],
family="render",

View file

@ -38,7 +38,7 @@ class CollectSlackFamilies(pyblish.api.InstancePlugin,
"families": family,
"tasks": task_data.get("name"),
"task_types": task_data.get("type"),
"hosts": instance.data["anatomyData"]["app"],
"hosts": instance.context.data["hostName"],
"subsets": instance.data["subset"]
}
profile = filter_profiles(self.profiles, key_values,

View file

@ -651,6 +651,13 @@ def _convert_3dsmax_project_settings(ayon_settings, output):
attributes = {}
ayon_publish["ValidateAttributes"]["attributes"] = attributes
if "ValidateLoadedPlugin" in ayon_publish:
loaded_plugin = (
ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"]
)
for item in loaded_plugin:
item["families"] = item.pop("product_types")
output["max"] = ayon_max
@ -933,6 +940,23 @@ def _convert_photoshop_project_settings(ayon_settings, output):
output["photoshop"] = ayon_photoshop
def _convert_substancepainter_project_settings(ayon_settings, output):
if "substancepainter" not in ayon_settings:
return
ayon_substance_painter = ayon_settings["substancepainter"]
_convert_host_imageio(ayon_substance_painter)
if "shelves" in ayon_substance_painter:
shelves_items = ayon_substance_painter["shelves"]
new_shelves_items = {
item["name"]: item["value"]
for item in shelves_items
}
ayon_substance_painter["shelves"] = new_shelves_items
output["substancepainter"] = ayon_substance_painter
def _convert_tvpaint_project_settings(ayon_settings, output):
if "tvpaint" not in ayon_settings:
return
@ -1391,6 +1415,7 @@ def convert_project_settings(ayon_settings, default_settings):
_convert_nuke_project_settings(ayon_settings, output)
_convert_hiero_project_settings(ayon_settings, output)
_convert_photoshop_project_settings(ayon_settings, output)
_convert_substancepainter_project_settings(ayon_settings, output)
_convert_tvpaint_project_settings(ayon_settings, output)
_convert_traypublisher_project_settings(ayon_settings, output)
_convert_webpublisher_project_settings(ayon_settings, output)

View file

@ -51,6 +51,11 @@
"ValidateAttributes": {
"enabled": false,
"attributes": {}
},
"ValidateLoadedPlugin": {
"enabled": false,
"optional": true,
"family_plugins_mapping": []
}
}
}

View file

@ -19,16 +19,16 @@
"rules": {}
},
"viewer": {
"viewerProcess": "sRGB"
"viewerProcess": "sRGB (default)"
},
"baking": {
"viewerProcess": "rec709"
"viewerProcess": "rec709 (default)"
},
"workfile": {
"colorManagement": "Nuke",
"colorManagement": "OCIO",
"OCIO_config": "nuke-default",
"workingSpaceLUT": "linear",
"monitorLut": "sRGB"
"workingSpaceLUT": "scene_linear",
"monitorLut": "sRGB (default)"
},
"nodes": {
"requiredNodes": [
@ -76,7 +76,7 @@
{
"type": "text",
"name": "colorspace",
"value": "linear"
"value": "scene_linear"
},
{
"type": "bool",
@ -129,7 +129,7 @@
{
"type": "text",
"name": "colorspace",
"value": "linear"
"value": "scene_linear"
},
{
"type": "bool",
@ -177,7 +177,7 @@
{
"type": "text",
"name": "colorspace",
"value": "sRGB"
"value": "texture_paint"
},
{
"type": "bool",
@ -193,7 +193,7 @@
"inputs": [
{
"regex": "(beauty).*(?=.exr)",
"colorspace": "linear"
"colorspace": "scene_linear"
}
]
}

View file

@ -47,6 +47,49 @@
"label": "Attributes"
}
]
},
{
"type": "dict",
"collapsible": true,
"key": "ValidateLoadedPlugin",
"label": "Validate Loaded Plugin",
"checkbox_key": "enabled",
"children": [
{
"type": "boolean",
"key": "enabled",
"label": "Enabled"
},
{
"type": "boolean",
"key": "optional",
"label": "Optional"
},
{
"type": "list",
"collapsible": true,
"key": "family_plugins_mapping",
"label": "Family Plugins Mapping",
"use_label_wrap": true,
"object_type": {
"type": "dict",
"children": [
{
"key": "families",
"label": "Famiies",
"type": "list",
"object_type": "text"
},
{
"key": "plugins",
"label": "Plugins",
"type": "list",
"object_type": "text"
}
]
}
}
]
}
]
}

View file

@ -1,4 +1,5 @@
import logging
import uuid
import ayon_api
@ -314,8 +315,21 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
containers = self._host.get_containers()
else:
containers = self._host.ls()
repre_ids = {c.get("representation") for c in containers}
repre_ids.discard(None)
repre_ids = set()
for container in containers:
repre_id = container.get("representation")
# Ignore invalid representation ids.
# - invalid representation ids may be available if e.g. is
# opened scene from OpenPype whe 'ObjectId' was used instead
# of 'uuid'.
# NOTE: Server call would crash if there is any invalid id.
# That would cause crash we won't get any information.
try:
uuid.UUID(repre_id)
repre_ids.add(repre_id)
except ValueError:
pass
product_ids = self._products_model.get_product_ids_by_repre_ids(
project_name, repre_ids
)

View file

@ -77,7 +77,15 @@ def product_item_from_entity(
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_item = product_type_items_by_name.get(product_type)
# NOTE This is needed for cases when products were not created on server
# using api functions. In that case product type item may not be
# available and we need to create a default.
if product_type_item is None:
product_type_item = create_default_product_type_item(product_type)
# Cache the item for future use
product_type_items_by_name[product_type] = product_type_item
product_type_icon = product_type_item.icon
product_icon = {
@ -117,6 +125,15 @@ def product_type_item_from_data(product_type_data):
return ProductTypeItem(product_type_data["name"], icon, True)
def create_default_product_type_item(product_type):
icon = {
"type": "awesome-font",
"name": "fa.folder",
"color": "#0091B2",
}
return ProductTypeItem(product_type, icon, True)
class ProductsModel:
"""Model for products, version and representation.

View file

@ -13,6 +13,7 @@ from .hierarchy import (
HIERARCHY_MODEL_SENDER,
)
from .thumbnails import ThumbnailsModel
from .selection import HierarchyExpectedSelection
__all__ = (
@ -29,4 +30,6 @@ __all__ = (
"HIERARCHY_MODEL_SENDER",
"ThumbnailsModel",
"HierarchyExpectedSelection",
)

View file

@ -81,11 +81,11 @@ class NestedCacheItem:
"""Helper for cached items stored in nested structure.
Example:
>>> cache = NestedCacheItem(levels=2)
>>> cache = NestedCacheItem(levels=2, default_factory=lambda: 0)
>>> cache["a"]["b"].is_valid
False
>>> cache["a"]["b"].get_data()
None
0
>>> cache["a"]["b"] = 1
>>> cache["a"]["b"].is_valid
True
@ -167,8 +167,51 @@ class NestedCacheItem:
return self[key]
def cached_count(self):
"""Amount of cached items.
Returns:
int: Amount of cached items.
"""
return len(self._data_by_key)
def clear_key(self, key):
"""Clear cached item by key.
Args:
key (str): Key of the cache item.
"""
self._data_by_key.pop(key, None)
def clear_invalid(self):
"""Clear all invalid cache items.
Note:
To clear all cache items use 'reset'.
"""
changed = {}
children_are_nested = self._levels > 1
for key, cache in tuple(self._data_by_key.items()):
if children_are_nested:
output = cache.clear_invalid()
if output:
changed[key] = output
if not cache.cached_count():
self._data_by_key.pop(key)
elif not cache.is_valid:
changed[key] = cache.get_data()
self._data_by_key.pop(key)
return changed
def reset(self):
"""Reset cache."""
"""Reset cache.
Note:
To clear only invalid cache items use 'clear_invalid'.
"""
self._data_by_key = {}

View file

@ -0,0 +1,179 @@
class _ExampleController:
def emit_event(self, topic, data, **kwargs):
pass
class HierarchyExpectedSelection:
"""Base skeleton of expected selection model.
Expected selection model holds information about which entities should be
selected. The order of selection is very important as change of project
will affect what folders are available in folders UI and so on. Because
of that should expected selection model know what is current entity
to select.
If any of 'handle_project', 'handle_folder' or 'handle_task' is set to
'False' expected selection data won't contain information about the
entity type at all. Also if project is not handled then it is not
necessary to call 'expected_project_selected'. Same goes for folder and
task.
Model is triggering event with 'expected_selection_changed' topic and
data > data structure is matching 'get_expected_selection_data' method.
Questions:
Require '_ExampleController' as abstraction?
Args:
controller (Any): Controller object. ('_ExampleController')
handle_project (bool): Project can be considered as can have expected
selection.
handle_folder (bool): Folder can be considered as can have expected
selection.
handle_task (bool): Task can be considered as can have expected
selection.
"""
def __init__(
self,
controller,
handle_project=True,
handle_folder=True,
handle_task=True
):
self._project_name = None
self._folder_id = None
self._task_name = None
self._project_selected = True
self._folder_selected = True
self._task_selected = True
self._controller = controller
self._handle_project = handle_project
self._handle_folder = handle_folder
self._handle_task = handle_task
def set_expected_selection(
self,
project_name=None,
folder_id=None,
task_name=None
):
"""Sets expected selection.
Args:
project_name (Optional[str]): Project name.
folder_id (Optional[str]): Folder id.
task_name (Optional[str]): Task name.
"""
self._project_name = project_name
self._folder_id = folder_id
self._task_name = task_name
self._project_selected = not self._handle_project
self._folder_selected = not self._handle_folder
self._task_selected = not self._handle_task
self._emit_change()
def get_expected_selection_data(self):
project_current = False
folder_current = False
task_current = False
if not self._project_selected:
project_current = True
elif not self._folder_selected:
folder_current = True
elif not self._task_selected:
task_current = True
data = {}
if self._handle_project:
data["project"] = {
"name": self._project_name,
"current": project_current,
"selected": self._project_selected,
}
if self._handle_folder:
data["folder"] = {
"id": self._folder_id,
"current": folder_current,
"selected": self._folder_selected,
}
if self._handle_task:
data["task"] = {
"name": self._task_name,
"current": task_current,
"selected": self._task_selected,
}
return data
def is_expected_project_selected(self, project_name):
if not self._handle_project:
return True
return project_name == self._project_name and self._project_selected
def is_expected_folder_selected(self, folder_id):
if not self._handle_folder:
return True
return folder_id == self._folder_id and self._folder_selected
def expected_project_selected(self, project_name):
"""UI selected requested project.
Other entity types can be requested for selection.
Args:
project_name (str): Name of project.
"""
if project_name != self._project_name:
return False
self._project_selected = True
self._emit_change()
return True
def expected_folder_selected(self, folder_id):
"""UI selected requested folder.
Other entity types can be requested for selection.
Args:
folder_id (str): Folder id.
"""
if folder_id != self._folder_id:
return False
self._folder_selected = True
self._emit_change()
return True
def expected_task_selected(self, folder_id, task_name):
"""UI selected requested task.
Other entity types can be requested for selection.
Because task name is not unique across project a folder id is also
required to confirm the right task has been selected.
Args:
folder_id (str): Folder id.
task_name (str): Task name.
"""
if self._folder_id != folder_id:
return False
if task_name != self._task_name:
return False
self._task_selected = True
self._emit_change()
return True
def _emit_change(self):
self._controller.emit_event(
"expected_selection_changed",
self.get_expected_selection_data(),
)

View file

@ -503,17 +503,6 @@ class ProjectsCombobox(QtWidgets.QWidget):
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)
@ -534,6 +523,17 @@ class ProjectsCombobox(QtWidgets.QWidget):
def set_library_filter_enabled(self, enabled):
return self._projects_proxy_model.set_library_filter_enabled(enabled)
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 _on_current_index_changed(self, idx):
if not self._listen_selection_change:
return

View file

@ -443,8 +443,11 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
pass
@abstractmethod
def get_project_entity(self):
"""Get current project entity.
def get_project_entity(self, project_name):
"""Get project entity by name.
Args:
project_name (str): Project name.
Returns:
dict[str, Any]: Project entity data.
@ -453,10 +456,11 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
pass
@abstractmethod
def get_folder_entity(self, folder_id):
def get_folder_entity(self, project_name, folder_id):
"""Get folder entity by id.
Args:
project_name (str): Project name.
folder_id (str): Folder id.
Returns:
@ -466,10 +470,11 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
pass
@abstractmethod
def get_task_entity(self, task_id):
def get_task_entity(self, project_name, task_id):
"""Get task entity by id.
Args:
project_name (str): Project name.
task_id (str): Task id.
Returns:
@ -574,12 +579,10 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
pass
@abstractmethod
def set_selected_task(self, folder_id, task_id, task_name):
def set_selected_task(self, task_id, task_name):
"""Change selected task.
Args:
folder_id (Union[str, None]): Folder id or None if no folder
is selected.
task_id (Union[str, None]): Task id or None if no task
is selected.
task_name (Union[str, None]): Task name or None if no task
@ -711,21 +714,27 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
pass
@abstractmethod
def expected_representation_selected(self, representation_id):
def expected_representation_selected(
self, folder_id, task_name, representation_id
):
"""Expected representation was selected in UI.
Args:
folder_id (str): Folder id under which representation is.
task_name (str): Task name under which representation is.
representation_id (str): Representation id which was selected.
"""
pass
@abstractmethod
def expected_workfile_selected(self, workfile_path):
def expected_workfile_selected(self, folder_id, task_name, workfile_name):
"""Expected workfile was selected in UI.
Args:
workfile_path (str): Workfile path which was selected.
folder_id (str): Folder id under which workfile is.
task_name (str): Task name under which workfile is.
workfile_name (str): Workfile filename which was selected.
"""
pass
@ -738,7 +747,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
# Model functions
@abstractmethod
def get_folder_items(self, sender):
def get_folder_items(self, project_name, sender):
"""Folder items to visualize project hierarchy.
This function may trigger events 'folders.refresh.started' and
@ -746,6 +755,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
That may help to avoid re-refresh of folder items in UI elements.
Args:
project_name (str): Project name for which are folders requested.
sender (str): Who requested folder items.
Returns:
@ -756,7 +766,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
pass
@abstractmethod
def get_task_items(self, folder_id, sender):
def get_task_items(self, project_name, folder_id, sender):
"""Task items.
This function may trigger events 'tasks.refresh.started' and
@ -764,6 +774,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
That may help to avoid re-refresh of task items in UI elements.
Args:
project_name (str): Project name for which are tasks requested.
folder_id (str): Folder ID for which are tasks requested.
sender (str): Who requested folder items.
@ -892,22 +903,25 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
At this moment the only information which can be saved about
workfile is 'note'.
When 'note' is 'None' it is only validated if workfile info exists,
and if not then creates one with empty note.
Args:
folder_id (str): Folder id.
task_id (str): Task id.
filepath (str): Workfile path.
note (str): Note.
note (Union[str, None]): Note.
"""
pass
# General commands
@abstractmethod
def refresh(self):
"""Refresh everything, models, ui etc.
def reset(self):
"""Reset everything, models, ui etc.
Triggers 'controller.refresh.started' event at the beginning and
'controller.refresh.finished' at the end.
Triggers 'controller.reset.started' event at the beginning and
'controller.reset.finished' at the end.
"""
pass

View file

@ -16,93 +16,120 @@ from openpype.pipeline.context_tools import (
)
from openpype.pipeline.workfile import create_workdir_extra_folders
from openpype.tools.ayon_utils.models import (
HierarchyModel,
HierarchyExpectedSelection,
ProjectsModel,
)
from .abstract import (
AbstractWorkfilesFrontend,
AbstractWorkfilesBackend,
)
from .models import SelectionModel, EntitiesModel, WorkfilesModel
from .models import SelectionModel, WorkfilesModel
class ExpectedSelection:
def __init__(self):
self._folder_id = None
self._task_name = None
class WorkfilesToolExpectedSelection(HierarchyExpectedSelection):
def __init__(self, controller):
super(WorkfilesToolExpectedSelection, self).__init__(
controller,
handle_project=False,
handle_folder=True,
handle_task=True,
)
self._workfile_name = None
self._representation_id = None
self._folder_selected = True
self._task_selected = True
self._workfile_name_selected = True
self._representation_id_selected = True
self._workfile_selected = True
self._representation_selected = True
def set_expected_selection(
self,
folder_id,
task_name,
project_name=None,
folder_id=None,
task_name=None,
workfile_name=None,
representation_id=None
representation_id=None,
):
self._folder_id = folder_id
self._task_name = task_name
self._workfile_name = workfile_name
self._representation_id = representation_id
self._folder_selected = False
self._task_selected = False
self._workfile_name_selected = workfile_name is None
self._representation_id_selected = representation_id is None
self._workfile_selected = False
self._representation_selected = False
super(WorkfilesToolExpectedSelection, self).set_expected_selection(
project_name,
folder_id,
task_name,
)
def get_expected_selection_data(self):
return {
"folder_id": self._folder_id,
"task_name": self._task_name,
"workfile_name": self._workfile_name,
"representation_id": self._representation_id,
"folder_selected": self._folder_selected,
"task_selected": self._task_selected,
"workfile_name_selected": self._workfile_name_selected,
"representation_id_selected": self._representation_id_selected,
data = super(
WorkfilesToolExpectedSelection, self
).get_expected_selection_data()
_is_current = (
self._project_selected
and self._folder_selected
and self._task_selected
)
workfile_is_current = False
repre_is_current = False
if _is_current:
workfile_is_current = not self._workfile_selected
repre_is_current = not self._representation_selected
data["workfile"] = {
"name": self._workfile_name,
"current": workfile_is_current,
"selected": self._workfile_selected,
}
data["representation"] = {
"id": self._representation_id,
"current": repre_is_current,
"selected": self._workfile_selected,
}
return data
def is_expected_folder_selected(self, folder_id):
return folder_id == self._folder_id and self._folder_selected
def is_expected_workfile_selected(self, workfile_name):
return (
workfile_name == self._workfile_name
and self._workfile_selected
)
def is_expected_task_selected(self, folder_id, task_name):
if not self.is_expected_folder_selected(folder_id):
return False
return task_name == self._task_name and self._task_selected
def is_expected_representation_selected(self, representation_id):
return (
representation_id == self._representation_id
and self._representation_selected
)
def expected_folder_selected(self, folder_id):
def expected_workfile_selected(self, folder_id, task_name, workfile_name):
if folder_id != self._folder_id:
return False
self._folder_selected = True
return True
def expected_task_selected(self, folder_id, task_name):
if not self.is_expected_folder_selected(folder_id):
return False
if task_name != self._task_name:
return False
self._task_selected = True
return True
def expected_workfile_selected(self, folder_id, task_name, workfile_name):
if not self.is_expected_task_selected(folder_id, task_name):
return False
if workfile_name != self._workfile_name:
return False
self._workfile_name_selected = True
self._workfile_selected = True
self._emit_change()
return True
def expected_representation_selected(
self, folder_id, task_name, representation_id
):
if not self.is_expected_task_selected(folder_id, task_name):
if folder_id != self._folder_id:
return False
if task_name != self._task_name:
return False
if representation_id != self._representation_id:
return False
self._representation_id_selected = True
self._representation_selected = True
self._emit_change()
return True
@ -136,9 +163,9 @@ class BaseWorkfileController(
# Expected selected folder and task
self._expected_selection = self._create_expected_selection_obj()
self._selection_model = self._create_selection_model()
self._entities_model = self._create_entities_model()
self._projects_model = self._create_projects_model()
self._hierarchy_model = self._create_hierarchy_model()
self._workfiles_model = self._create_workfiles_model()
@property
@ -151,13 +178,16 @@ class BaseWorkfileController(
return self._host_is_valid
def _create_expected_selection_obj(self):
return ExpectedSelection()
return WorkfilesToolExpectedSelection(self)
def _create_projects_model(self):
return ProjectsModel(self)
def _create_selection_model(self):
return SelectionModel(self)
def _create_entities_model(self):
return EntitiesModel(self)
def _create_hierarchy_model(self):
return HierarchyModel(self)
def _create_workfiles_model(self):
return WorkfilesModel(self)
@ -193,14 +223,17 @@ class BaseWorkfileController(
self._project_anatomy = Anatomy(self.get_current_project_name())
return self._project_anatomy
def get_project_entity(self):
return self._entities_model.get_project_entity()
def get_project_entity(self, project_name):
return self._projects_model.get_project_entity(
project_name)
def get_folder_entity(self, folder_id):
return self._entities_model.get_folder_entity(folder_id)
def get_folder_entity(self, project_name, folder_id):
return self._hierarchy_model.get_folder_entity(
project_name, folder_id)
def get_task_entity(self, task_id):
return self._entities_model.get_task_entity(task_id)
def get_task_entity(self, project_name, task_id):
return self._hierarchy_model.get_task_entity(
project_name, task_id)
# ---------------------------------
# Implementation of abstract methods
@ -293,9 +326,8 @@ class BaseWorkfileController(
def get_selected_task_name(self):
return self._selection_model.get_selected_task_name()
def set_selected_task(self, folder_id, task_id, task_name):
return self._selection_model.set_selected_task(
folder_id, task_id, task_name)
def set_selected_task(self, task_id, task_name):
return self._selection_model.set_selected_task(task_id, task_name)
def get_selected_workfile_path(self):
return self._selection_model.get_selected_workfile_path()
@ -318,7 +350,11 @@ class BaseWorkfileController(
representation_id=None
):
self._expected_selection.set_expected_selection(
folder_id, task_name, workfile_name, representation_id
self.get_current_project_name(),
folder_id,
task_name,
workfile_name,
representation_id
)
self._trigger_expected_selection_changed()
@ -355,11 +391,13 @@ class BaseWorkfileController(
)
# Model functions
def get_folder_items(self, sender):
return self._entities_model.get_folder_items(sender)
def get_folder_items(self, project_name, sender=None):
return self._hierarchy_model.get_folder_items(project_name, sender)
def get_task_items(self, folder_id, sender):
return self._entities_model.get_tasks_items(folder_id, sender)
def get_task_items(self, project_name, folder_id, sender=None):
return self._hierarchy_model.get_task_items(
project_name, folder_id, sender
)
def get_workarea_dir_by_context(self, folder_id, task_id):
return self._workfiles_model.get_workarea_dir_by_context(
@ -394,7 +432,9 @@ class BaseWorkfileController(
def get_published_file_items(self, folder_id, task_id):
task_name = None
if task_id:
task = self.get_task_entity(task_id)
task = self.get_task_entity(
self.get_current_project_name(), task_id
)
task_name = task.get("name")
return self._workfiles_model.get_published_file_items(
@ -410,21 +450,27 @@ class BaseWorkfileController(
folder_id, task_id, filepath, note
)
def refresh(self):
def reset(self):
if not self._host_is_valid:
self._emit_event("controller.refresh.started")
self._emit_event("controller.refresh.finished")
self._emit_event("controller.reset.started")
self._emit_event("controller.reset.finished")
return
expected_folder_id = self.get_selected_folder_id()
expected_task_name = self.get_selected_task_name()
expected_work_path = self.get_selected_workfile_path()
expected_repre_id = self.get_selected_representation_id()
expected_work_name = None
if expected_work_path:
expected_work_name = os.path.basename(expected_work_path)
self._emit_event("controller.refresh.started")
self._emit_event("controller.reset.started")
context = self._get_host_current_context()
project_name = context["project_name"]
folder_name = context["asset_name"]
task_name = context["task_name"]
current_file = self.get_current_workfile()
folder_id = None
if folder_name:
folder = ayon_api.get_folder_by_name(project_name, folder_name)
@ -439,18 +485,25 @@ class BaseWorkfileController(
self._current_folder_id = folder_id
self._current_task_name = task_name
self._projects_model.reset()
self._hierarchy_model.reset()
if not expected_folder_id:
expected_folder_id = folder_id
expected_task_name = task_name
if current_file:
expected_work_name = os.path.basename(current_file)
self._emit_event("controller.reset.finished")
self._expected_selection.set_expected_selection(
expected_folder_id, expected_task_name
project_name,
expected_folder_id,
expected_task_name,
expected_work_name,
expected_repre_id,
)
self._entities_model.refresh()
self._emit_event("controller.refresh.finished")
# Controller actions
def open_workfile(self, folder_id, task_id, filepath):
self._emit_event("open_workfile.started")
@ -579,9 +632,9 @@ class BaseWorkfileController(
self, project_name, folder_id, task_id, folder=None, task=None
):
if folder is None:
folder = self.get_folder_entity(folder_id)
folder = self.get_folder_entity(project_name, folder_id)
if task is None:
task = self.get_task_entity(task_id)
task = self.get_task_entity(project_name, task_id)
# NOTE keys should be OpenPype compatible
return {
"project_name": project_name,
@ -633,8 +686,8 @@ class BaseWorkfileController(
):
# Trigger before save event
project_name = self.get_current_project_name()
folder = self.get_folder_entity(folder_id)
task = self.get_task_entity(task_id)
folder = self.get_folder_entity(project_name, folder_id)
task = self.get_task_entity(project_name, task_id)
task_name = task["name"]
# QUESTION should the data be different for 'before' and 'after'?
@ -674,6 +727,9 @@ class BaseWorkfileController(
else:
self._host_save_workfile(dst_filepath)
# Make sure workfile info exists
self.save_workfile_info(folder_id, task_id, dst_filepath, None)
# Create extra folders
create_workdir_extra_folders(
workdir,
@ -685,4 +741,4 @@ class BaseWorkfileController(
# Trigger after save events
emit_event("workfile.save.after", event_data, source="workfiles.tool")
self.refresh()
self.reset()

View file

@ -1,10 +1,8 @@
from .hierarchy import EntitiesModel
from .selection import SelectionModel
from .workfiles import WorkfilesModel
__all__ = (
"SelectionModel",
"EntitiesModel",
"WorkfilesModel",
)

View file

@ -1,236 +0,0 @@
"""Hierarchy model that handles folders and tasks.
The model can be extracted for common usage. In that case it will be required
to add more handling of project name changes.
"""
import time
import collections
import contextlib
import ayon_api
from openpype.tools.ayon_workfiles.abstract import (
FolderItem,
TaskItem,
)
def _get_task_items_from_tasks(tasks):
"""
Returns:
TaskItem: Task item.
"""
output = []
for task in tasks:
folder_id = task["folderId"]
output.append(TaskItem(
task["id"],
task["name"],
task["type"],
folder_id,
None,
None
))
return output
def _get_folder_item_from_hierarchy_item(item):
return FolderItem(
item["id"],
item["parentId"],
item["name"],
item["label"],
None,
None,
)
class CacheItem:
def __init__(self, lifetime=120):
self._lifetime = lifetime
self._last_update = None
self._data = None
@property
def is_valid(self):
if self._last_update is None:
return False
return (time.time() - self._last_update) < self._lifetime
def set_invalid(self, data=None):
self._last_update = None
self._data = data
def get_data(self):
return self._data
def update_data(self, data):
self._data = data
self._last_update = time.time()
class EntitiesModel(object):
event_source = "entities.model"
def __init__(self, controller):
project_cache = CacheItem()
project_cache.set_invalid({})
folders_cache = CacheItem()
folders_cache.set_invalid({})
self._project_cache = project_cache
self._folders_cache = folders_cache
self._tasks_cache = {}
self._folders_by_id = {}
self._tasks_by_id = {}
self._folders_refreshing = False
self._tasks_refreshing = set()
self._controller = controller
def reset(self):
self._project_cache.set_invalid({})
self._folders_cache.set_invalid({})
self._tasks_cache = {}
self._folders_by_id = {}
self._tasks_by_id = {}
def refresh(self):
self._refresh_folders_cache()
def get_project_entity(self):
if not self._project_cache.is_valid:
project_name = self._controller.get_current_project_name()
project_entity = ayon_api.get_project(project_name)
self._project_cache.update_data(project_entity)
return self._project_cache.get_data()
def get_folder_items(self, sender):
if not self._folders_cache.is_valid:
self._refresh_folders_cache(sender)
return self._folders_cache.get_data()
def get_tasks_items(self, folder_id, sender):
if not folder_id:
return []
task_cache = self._tasks_cache.get(folder_id)
if task_cache is None or not task_cache.is_valid:
self._refresh_tasks_cache(folder_id, sender)
task_cache = self._tasks_cache.get(folder_id)
return task_cache.get_data()
def get_folder_entity(self, folder_id):
if folder_id not in self._folders_by_id:
entity = None
if folder_id:
project_name = self._controller.get_current_project_name()
entity = ayon_api.get_folder_by_id(project_name, folder_id)
self._folders_by_id[folder_id] = entity
return self._folders_by_id[folder_id]
def get_task_entity(self, task_id):
if task_id not in self._tasks_by_id:
entity = None
if task_id:
project_name = self._controller.get_current_project_name()
entity = ayon_api.get_task_by_id(project_name, task_id)
self._tasks_by_id[task_id] = entity
return self._tasks_by_id[task_id]
@contextlib.contextmanager
def _folder_refresh_event_manager(self, project_name, sender):
self._folders_refreshing = True
self._controller.emit_event(
"folders.refresh.started",
{"project_name": project_name, "sender": sender},
self.event_source
)
try:
yield
finally:
self._controller.emit_event(
"folders.refresh.finished",
{"project_name": project_name, "sender": sender},
self.event_source
)
self._folders_refreshing = False
@contextlib.contextmanager
def _task_refresh_event_manager(
self, project_name, folder_id, sender
):
self._tasks_refreshing.add(folder_id)
self._controller.emit_event(
"tasks.refresh.started",
{
"project_name": project_name,
"folder_id": folder_id,
"sender": sender,
},
self.event_source
)
try:
yield
finally:
self._controller.emit_event(
"tasks.refresh.finished",
{
"project_name": project_name,
"folder_id": folder_id,
"sender": sender,
},
self.event_source
)
self._tasks_refreshing.discard(folder_id)
def _refresh_folders_cache(self, sender=None):
if self._folders_refreshing:
return
project_name = self._controller.get_current_project_name()
with self._folder_refresh_event_manager(project_name, sender):
folder_items = self._query_folders(project_name)
self._folders_cache.update_data(folder_items)
def _query_folders(self, project_name):
hierarchy = ayon_api.get_folders_hierarchy(project_name)
folder_items = {}
hierachy_queue = collections.deque(hierarchy["hierarchy"])
while hierachy_queue:
item = hierachy_queue.popleft()
folder_item = _get_folder_item_from_hierarchy_item(item)
folder_items[folder_item.entity_id] = folder_item
hierachy_queue.extend(item["children"] or [])
return folder_items
def _refresh_tasks_cache(self, folder_id, sender=None):
if folder_id in self._tasks_refreshing:
return
project_name = self._controller.get_current_project_name()
with self._task_refresh_event_manager(
project_name, folder_id, sender
):
cache_item = self._tasks_cache.get(folder_id)
if cache_item is None:
cache_item = CacheItem()
self._tasks_cache[folder_id] = cache_item
task_items = self._query_tasks(project_name, folder_id)
cache_item.update_data(task_items)
def _query_tasks(self, project_name, folder_id):
tasks = list(ayon_api.get_tasks(
project_name,
folder_ids=[folder_id],
fields={"id", "name", "label", "folderId", "type"}
))
return _get_task_items_from_tasks(tasks)

View file

@ -4,7 +4,7 @@ class SelectionModel(object):
Triggering events:
- "selection.folder.changed"
- "selection.task.changed"
- "workarea.selection.changed"
- "selection.workarea.changed"
- "selection.representation.changed"
"""
@ -29,7 +29,10 @@ class SelectionModel(object):
self._folder_id = folder_id
self._controller.emit_event(
"selection.folder.changed",
{"folder_id": folder_id},
{
"project_name": self._controller.get_current_project_name(),
"folder_id": folder_id
},
self.event_source
)
@ -39,10 +42,7 @@ class SelectionModel(object):
def get_selected_task_id(self):
return self._task_id
def set_selected_task(self, folder_id, task_id, task_name):
if folder_id != self._folder_id:
self.set_selected_folder(folder_id)
def set_selected_task(self, task_id, task_name):
if task_id == self._task_id:
return
@ -51,7 +51,8 @@ class SelectionModel(object):
self._controller.emit_event(
"selection.task.changed",
{
"folder_id": folder_id,
"project_name": self._controller.get_current_project_name(),
"folder_id": self._folder_id,
"task_name": task_name,
"task_id": task_id
},
@ -67,8 +68,9 @@ class SelectionModel(object):
self._workfile_path = path
self._controller.emit_event(
"workarea.selection.changed",
"selection.workarea.changed",
{
"project_name": self._controller.get_current_project_name(),
"path": path,
"folder_id": self._folder_id,
"task_name": self._task_name,
@ -86,6 +88,9 @@ class SelectionModel(object):
self._representation_id = representation_id
self._controller.emit_event(
"selection.representation.changed",
{"representation_id": representation_id},
{
"project_name": self._controller.get_current_project_name(),
"representation_id": representation_id,
},
self.event_source
)

View file

@ -148,7 +148,9 @@ class WorkareaModel:
def _get_folder_data(self, folder_id):
fill_data = self._fill_data_by_folder_id.get(folder_id)
if fill_data is None:
folder = self._controller.get_folder_entity(folder_id)
folder = self._controller.get_folder_entity(
self.project_name, folder_id
)
fill_data = get_folder_template_data(folder)
self._fill_data_by_folder_id[folder_id] = fill_data
return copy.deepcopy(fill_data)
@ -156,7 +158,9 @@ class WorkareaModel:
def _get_task_data(self, project_entity, folder_id, task_id):
task_data = self._task_data_by_folder_id.setdefault(folder_id, {})
if task_id not in task_data:
task = self._controller.get_task_entity(task_id)
task = self._controller.get_task_entity(
self.project_name, task_id
)
if task:
task_data[task_id] = get_task_template_data(
project_entity, task)
@ -167,8 +171,9 @@ class WorkareaModel:
return {}
base_data = self._get_base_data()
project_name = base_data["project"]["name"]
folder_data = self._get_folder_data(folder_id)
project_entity = self._controller.get_project_entity()
project_entity = self._controller.get_project_entity(project_name)
task_data = self._get_task_data(project_entity, folder_id, task_id)
base_data.update(folder_data)
@ -292,9 +297,13 @@ class WorkareaModel:
folder = None
task = None
if folder_id:
folder = self._controller.get_folder_entity(folder_id)
folder = self._controller.get_folder_entity(
self.project_name, folder_id
)
if task_id:
task = self._controller.get_task_entity(task_id)
task = self._controller.get_task_entity(
self.project_name, task_id
)
if not folder or not task:
return {
@ -491,10 +500,13 @@ class WorkfileEntitiesModel:
)
if not workfile_info:
self._cache[identifier] = self._create_workfile_info_entity(
task_id, rootless_path, note)
task_id, rootless_path, note or "")
self._items.pop(identifier, None)
return
if note is None:
return
new_workfile_info = copy.deepcopy(workfile_info)
attrib = new_workfile_info.setdefault("attrib", {})
attrib["description"] = note

View file

@ -69,7 +69,7 @@ class FilesWidget(QtWidgets.QWidget):
main_layout.addWidget(btns_widget, 0)
controller.register_event_callback(
"workarea.selection.changed",
"selection.workarea.changed",
self._on_workarea_path_changed
)
controller.register_event_callback(

View file

@ -59,14 +59,6 @@ class PublishedFilesModel(QtGui.QStandardItemModel):
self._add_empty_item()
def _clear_items(self):
self._remove_missing_context_item()
self._remove_empty_item()
if self._items_by_id:
root = self.invisibleRootItem()
root.removeRows(0, root.rowCount())
self._items_by_id = {}
def set_published_mode(self, published_mode):
if self._published_mode == published_mode:
return
@ -89,6 +81,18 @@ class PublishedFilesModel(QtGui.QStandardItemModel):
return QtCore.QModelIndex()
return self.indexFromItem(item)
def refresh(self):
if self._published_mode:
self._fill_items()
def _clear_items(self):
self._remove_missing_context_item()
self._remove_empty_item()
if self._items_by_id:
root = self.invisibleRootItem()
root.removeRows(0, root.rowCount())
self._items_by_id = {}
def _get_missing_context_item(self):
if self._missing_context_item is None:
message = "Select folder"
@ -149,7 +153,6 @@ class PublishedFilesModel(QtGui.QStandardItemModel):
def _on_folder_changed(self, event):
self._last_folder_id = event["folder_id"]
self._last_task_id = None
if self._context_select_mode:
return
@ -356,14 +359,13 @@ class PublishedFilesWidget(QtWidgets.QWidget):
self.save_as_requested.emit()
def _on_expected_selection_change(self, event):
if (
event["representation_id_selected"]
or not event["folder_selected"]
or (event["task_name"] and not event["task_selected"])
):
repre_info = event["representation"]
if not repre_info["current"]:
return
representation_id = event["representation_id"]
self._model.refresh()
representation_id = repre_info["id"]
selected_repre_id = self.get_selected_repre_id()
if (
representation_id is not None
@ -376,5 +378,5 @@ class PublishedFilesWidget(QtWidgets.QWidget):
self._view.setCurrentIndex(proxy_index)
self._controller.expected_representation_selected(
event["folder_id"], event["task_name"], representation_id
event["folder"]["id"], event["task"]["name"], representation_id
)

View file

@ -28,6 +28,10 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel):
self.setHeaderData(0, QtCore.Qt.Horizontal, "Name")
self.setHeaderData(1, QtCore.Qt.Horizontal, "Date Modified")
controller.register_event_callback(
"selection.folder.changed",
self._on_folder_changed
)
controller.register_event_callback(
"selection.task.changed",
self._on_task_changed
@ -63,6 +67,10 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel):
return QtCore.QModelIndex()
return self.indexFromItem(item)
def refresh(self):
if not self._published_mode:
self._fill_items()
def _get_missing_context_item(self):
if self._missing_context_item is None:
message = "Select folder and task"
@ -129,6 +137,11 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel):
root_item.takeRow(self._empty_root_item.row())
self._empty_item_used = False
def _on_folder_changed(self, event):
self._selected_folder_id = event["folder_id"]
if not self._published_mode:
self._fill_items()
def _on_task_changed(self, event):
self._selected_folder_id = event["folder_id"]
self._selected_task_id = event["task_id"]
@ -362,10 +375,13 @@ class WorkAreaFilesWidget(QtWidgets.QWidget):
self.duplicate_requested.emit()
def _on_expected_selection_change(self, event):
if event["workfile_name_selected"]:
workfile_info = event["workfile"]
if not workfile_info["current"]:
return
workfile_name = event["workfile_name"]
self._model.refresh()
workfile_name = workfile_info["name"]
if (
workfile_name is not None
and workfile_name != self._get_selected_info()["filename"]
@ -376,5 +392,5 @@ class WorkAreaFilesWidget(QtWidgets.QWidget):
self._view.setCurrentIndex(proxy_index)
self._controller.expected_workfile_selected(
event["folder_id"], event["task_name"], workfile_name
event["folder"]["id"], event["task"]["name"], workfile_name
)

View file

@ -1,324 +0,0 @@
import uuid
import collections
import qtawesome
from qtpy import QtWidgets, QtGui, QtCore
from openpype.tools.utils import (
RecursiveSortFilterProxyModel,
DeselectableTreeView,
)
from .constants import ITEM_ID_ROLE, ITEM_NAME_ROLE
SENDER_NAME = "qt_folders_model"
class FoldersRefreshThread(QtCore.QThread):
"""Thread for refreshing folders.
Call controller to get folders and emit signal when finished.
Args:
controller (AbstractWorkfilesFrontend): The control object.
"""
refresh_finished = QtCore.Signal(str)
def __init__(self, controller):
super(FoldersRefreshThread, self).__init__()
self._id = uuid.uuid4().hex
self._controller = controller
self._result = None
@property
def id(self):
"""Thread id.
Returns:
str: Unique id of the thread.
"""
return self._id
def run(self):
self._result = self._controller.get_folder_items(SENDER_NAME)
self.refresh_finished.emit(self.id)
def get_result(self):
return self._result
class FoldersModel(QtGui.QStandardItemModel):
"""Folders model which cares about refresh of folders.
Args:
controller (AbstractWorkfilesFrontend): The control object.
"""
refreshed = QtCore.Signal()
def __init__(self, controller):
super(FoldersModel, self).__init__()
self._controller = controller
self._items_by_id = {}
self._parent_id_by_id = {}
self._refresh_threads = {}
self._current_refresh_thread = None
self._has_content = False
self._is_refreshing = False
@property
def is_refreshing(self):
"""Model is refreshing.
Returns:
bool: True if model is refreshing.
"""
return self._is_refreshing
@property
def has_content(self):
"""Has at least one folder.
Returns:
bool: True if model has at least one folder.
"""
return self._has_content
def clear(self):
self._items_by_id = {}
self._parent_id_by_id = {}
self._has_content = False
super(FoldersModel, self).clear()
def get_index_by_id(self, item_id):
"""Get index by folder id.
Returns:
QtCore.QModelIndex: Index of the folder. Can be invalid if folder
is not available.
"""
item = self._items_by_id.get(item_id)
if item is None:
return QtCore.QModelIndex()
return self.indexFromItem(item)
def refresh(self):
"""Refresh folders items.
Refresh start thread because it can cause that controller can
start query from database if folders are not cached.
"""
self._is_refreshing = True
thread = FoldersRefreshThread(self._controller)
self._current_refresh_thread = thread.id
self._refresh_threads[thread.id] = thread
thread.refresh_finished.connect(self._on_refresh_thread)
thread.start()
def _on_refresh_thread(self, thread_id):
"""Callback when refresh thread is finished.
Technically can be running multiple refresh threads at the same time,
to avoid using values from wrong thread, we check if thread id is
current refresh thread id.
Folders are stored by id.
Args:
thread_id (str): Thread id.
"""
thread = self._refresh_threads.pop(thread_id)
if thread_id != self._current_refresh_thread:
return
folder_items_by_id = thread.get_result()
if not folder_items_by_id:
if folder_items_by_id is not None:
self.clear()
self._is_refreshing = False
return
self._has_content = True
folder_ids = set(folder_items_by_id)
ids_to_remove = set(self._items_by_id) - folder_ids
folder_items_by_parent = collections.defaultdict(list)
for folder_item in folder_items_by_id.values():
folder_items_by_parent[folder_item.parent_id].append(folder_item)
hierarchy_queue = collections.deque()
hierarchy_queue.append(None)
while hierarchy_queue:
parent_id = hierarchy_queue.popleft()
folder_items = folder_items_by_parent[parent_id]
if parent_id is None:
parent_item = self.invisibleRootItem()
else:
parent_item = self._items_by_id[parent_id]
new_items = []
for folder_item in folder_items:
item_id = folder_item.entity_id
item = self._items_by_id.get(item_id)
if item is None:
is_new = True
item = QtGui.QStandardItem()
item.setEditable(False)
else:
is_new = self._parent_id_by_id[item_id] != parent_id
icon = qtawesome.icon(
folder_item.icon_name,
color=folder_item.icon_color,
)
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)
if is_new:
new_items.append(item)
self._items_by_id[item_id] = item
self._parent_id_by_id[item_id] = parent_id
hierarchy_queue.append(item_id)
if new_items:
parent_item.appendRows(new_items)
for item_id in ids_to_remove:
item = self._items_by_id[item_id]
parent_id = self._parent_id_by_id[item_id]
if parent_id is None:
parent_item = self.invisibleRootItem()
else:
parent_item = self._items_by_id[parent_id]
parent_item.takeChild(item.row())
for item_id in ids_to_remove:
self._items_by_id.pop(item_id)
self._parent_id_by_id.pop(item_id)
self._is_refreshing = False
self.refreshed.emit()
class FoldersWidget(QtWidgets.QWidget):
"""Folders widget.
Widget that handles folders view, model and selection.
Args:
controller (AbstractWorkfilesFrontend): The control object.
parent (QtWidgets.QWidget): The parent widget.
"""
def __init__(self, controller, parent):
super(FoldersWidget, self).__init__(parent)
folders_view = DeselectableTreeView(self)
folders_view.setHeaderHidden(True)
folders_model = FoldersModel(controller)
folders_proxy_model = RecursiveSortFilterProxyModel()
folders_proxy_model.setSourceModel(folders_model)
folders_view.setModel(folders_proxy_model)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(folders_view, 1)
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._expected_selection = None
def set_name_filter(self, name):
self._folders_proxy_model.setFilterFixedString(name)
def _clear(self):
self._folders_model.clear()
def _on_folders_refresh_finished(self, event):
if event["sender"] != SENDER_NAME:
self._folders_model.refresh()
def _on_controller_refresh(self):
self._update_expected_selection()
def _update_expected_selection(self, expected_data=None):
if expected_data is None:
expected_data = self._controller.get_expected_selection_data()
# We're done
if expected_data["folder_selected"]:
return
folder_id = expected_data["folder_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
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)
self._controller.expected_folder_selected(folder_id)
def _on_model_refresh(self):
if self._expected_selection:
self._set_expected_selection()
self._folders_proxy_model.sort(0)
def _on_expected_selection_change(self, event):
self._update_expected_selection(event.data)
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)
if item_id is not None:
return item_id
return None
def _on_selection_change(self):
item_id = self._get_selected_item_id()
self._controller.set_selected_folder(item_id)

View file

@ -66,7 +66,7 @@ class SidePanelWidget(QtWidgets.QWidget):
btn_note_save.clicked.connect(self._on_save_click)
controller.register_event_callback(
"workarea.selection.changed", self._on_selection_change
"selection.workarea.changed", self._on_selection_change
)
self._details_input = details_input

View file

@ -1,420 +0,0 @@
import uuid
import qtawesome
from qtpy import QtWidgets, QtGui, QtCore
from openpype.style import get_disabled_entity_icon_color
from openpype.tools.utils import DeselectableTreeView
from .constants import (
ITEM_NAME_ROLE,
ITEM_ID_ROLE,
PARENT_ID_ROLE,
)
SENDER_NAME = "qt_tasks_model"
class RefreshThread(QtCore.QThread):
"""Thread for refreshing tasks.
Call controller to get tasks and emit signal when finished.
Args:
controller (AbstractWorkfilesFrontend): The control object.
folder_id (str): Folder id.
"""
refresh_finished = QtCore.Signal(str)
def __init__(self, controller, folder_id):
super(RefreshThread, self).__init__()
self._id = uuid.uuid4().hex
self._controller = controller
self._folder_id = folder_id
self._result = None
@property
def id(self):
return self._id
def run(self):
self._result = self._controller.get_task_items(
self._folder_id, SENDER_NAME)
self.refresh_finished.emit(self.id)
def get_result(self):
return self._result
class TasksModel(QtGui.QStandardItemModel):
"""Tasks model which cares about refresh of tasks by folder id.
Args:
controller (AbstractWorkfilesFrontend): The control object.
"""
refreshed = QtCore.Signal()
def __init__(self, controller):
super(TasksModel, self).__init__()
self._controller = controller
self._items_by_name = {}
self._has_content = False
self._is_refreshing = False
self._invalid_selection_item_used = False
self._invalid_selection_item = None
self._empty_tasks_item_used = False
self._empty_tasks_item = None
self._last_folder_id = None
self._refresh_threads = {}
self._current_refresh_thread = None
# Initial state
self._add_invalid_selection_item()
def clear(self):
self._items_by_name = {}
self._has_content = False
self._remove_invalid_items()
super(TasksModel, self).clear()
def refresh(self, folder_id):
"""Refresh tasks for folder.
Args:
folder_id (Union[str, None]): Folder id.
"""
self._refresh(folder_id)
def get_index_by_name(self, task_name):
"""Find item by name and return its index.
Returns:
QtCore.QModelIndex: Index of item. Is invalid if task is not
found by name.
"""
item = self._items_by_name.get(task_name)
if item is None:
return QtCore.QModelIndex()
return self.indexFromItem(item)
def get_last_folder_id(self):
"""Get last refreshed folder id.
Returns:
Union[str, None]: Folder id.
"""
return self._last_folder_id
def _get_invalid_selection_item(self):
if self._invalid_selection_item is None:
item = QtGui.QStandardItem("Select a folder")
item.setFlags(QtCore.Qt.NoItemFlags)
icon = qtawesome.icon(
"fa.times",
color=get_disabled_entity_icon_color()
)
item.setData(icon, QtCore.Qt.DecorationRole)
self._invalid_selection_item = item
return self._invalid_selection_item
def _get_empty_task_item(self):
if self._empty_tasks_item is None:
item = QtGui.QStandardItem("No task")
icon = qtawesome.icon(
"fa.exclamation-circle",
color=get_disabled_entity_icon_color()
)
item.setData(icon, QtCore.Qt.DecorationRole)
item.setFlags(QtCore.Qt.NoItemFlags)
self._empty_tasks_item = item
return self._empty_tasks_item
def _add_invalid_item(self, item):
self.clear()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _remove_invalid_item(self, item):
root_item = self.invisibleRootItem()
root_item.takeRow(item.row())
def _remove_invalid_items(self):
self._remove_invalid_selection_item()
self._remove_empty_task_item()
def _add_invalid_selection_item(self):
if not self._invalid_selection_item_used:
self._add_invalid_item(self._get_invalid_selection_item())
self._invalid_selection_item_used = True
def _remove_invalid_selection_item(self):
if self._invalid_selection_item:
self._remove_invalid_item(self._get_invalid_selection_item())
self._invalid_selection_item_used = False
def _add_empty_task_item(self):
if not self._empty_tasks_item_used:
self._add_invalid_item(self._get_empty_task_item())
self._empty_tasks_item_used = True
def _remove_empty_task_item(self):
if self._empty_tasks_item_used:
self._remove_invalid_item(self._get_empty_task_item())
self._empty_tasks_item_used = False
def _refresh(self, folder_id):
self._is_refreshing = True
self._last_folder_id = folder_id
if not folder_id:
self._add_invalid_selection_item()
self._current_refresh_thread = None
self._is_refreshing = False
self.refreshed.emit()
return
thread = RefreshThread(self._controller, folder_id)
self._current_refresh_thread = thread.id
self._refresh_threads[thread.id] = thread
thread.refresh_finished.connect(self._on_refresh_thread)
thread.start()
def _on_refresh_thread(self, thread_id):
"""Callback when refresh thread is finished.
Technically can be running multiple refresh threads at the same time,
to avoid using values from wrong thread, we check if thread id is
current refresh thread id.
Tasks are stored by name, so if a folder has same task name as
previously selected folder it keeps the selection.
Args:
thread_id (str): Thread id.
"""
thread = self._refresh_threads.pop(thread_id)
if thread_id != self._current_refresh_thread:
return
task_items = thread.get_result()
# Task items are refreshed
if task_items is None:
return
# No tasks are available on folder
if not task_items:
self._add_empty_task_item()
return
self._remove_invalid_items()
new_items = []
new_names = set()
for task_item in task_items:
name = task_item.name
new_names.add(name)
item = self._items_by_name.get(name)
if item is None:
item = QtGui.QStandardItem()
item.setEditable(False)
new_items.append(item)
self._items_by_name[name] = item
# TODO cache locally
icon = qtawesome.icon(
task_item.icon_name,
color=task_item.icon_color,
)
item.setData(task_item.label, QtCore.Qt.DisplayRole)
item.setData(name, ITEM_NAME_ROLE)
item.setData(task_item.id, ITEM_ID_ROLE)
item.setData(task_item.parent_id, PARENT_ID_ROLE)
item.setData(icon, QtCore.Qt.DecorationRole)
root_item = self.invisibleRootItem()
for name in set(self._items_by_name) - new_names:
item = self._items_by_name.pop(name)
root_item.removeRow(item.row())
if new_items:
root_item.appendRows(new_items)
self._has_content = root_item.rowCount() > 0
self._is_refreshing = False
self.refreshed.emit()
@property
def is_refreshing(self):
"""Model is refreshing.
Returns:
bool: Model is refreshing
"""
return self._is_refreshing
@property
def has_content(self):
"""Model has content.
Returns:
bools: Have at least one task.
"""
return self._has_content
def headerData(self, section, orientation, role):
# Show nice labels in the header
if (
role == QtCore.Qt.DisplayRole
and orientation == QtCore.Qt.Horizontal
):
if section == 0:
return "Tasks"
return super(TasksModel, self).headerData(
section, orientation, role
)
class TasksWidget(QtWidgets.QWidget):
"""Tasks widget.
Widget that handles tasks view, model and selection.
Args:
controller (AbstractWorkfilesFrontend): Workfiles controller.
"""
def __init__(self, controller, parent):
super(TasksWidget, self).__init__(parent)
tasks_view = DeselectableTreeView(self)
tasks_view.setIndentation(0)
tasks_model = TasksModel(controller)
tasks_proxy_model = QtCore.QSortFilterProxyModel()
tasks_proxy_model.setSourceModel(tasks_model)
tasks_view.setModel(tasks_proxy_model)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(tasks_view, 1)
controller.register_event_callback(
"tasks.refresh.finished",
self._on_tasks_refresh_finished
)
controller.register_event_callback(
"selection.folder.changed",
self._folder_selection_changed
)
controller.register_event_callback(
"expected_selection_changed",
self._on_expected_selection_change
)
selection_model = tasks_view.selectionModel()
selection_model.selectionChanged.connect(self._on_selection_change)
tasks_model.refreshed.connect(self._on_tasks_model_refresh)
self._controller = controller
self._tasks_view = tasks_view
self._tasks_model = tasks_model
self._tasks_proxy_model = tasks_proxy_model
self._selected_folder_id = None
self._expected_selection_data = None
def _clear(self):
self._tasks_model.clear()
def _on_tasks_refresh_finished(self, event):
"""Tasks were refreshed in controller.
Ignore if refresh was triggered by tasks model, or refreshed folder is
not the same as currently selected folder.
Args:
event (Event): Event object.
"""
# Refresh only if current folder id is the same
if (
event["sender"] == SENDER_NAME
or event["folder_id"] != self._selected_folder_id
):
return
self._tasks_model.refresh(self._selected_folder_id)
def _folder_selection_changed(self, event):
self._selected_folder_id = event["folder_id"]
self._tasks_model.refresh(self._selected_folder_id)
def _on_tasks_model_refresh(self):
if not self._set_expected_selection():
self._on_selection_change()
self._tasks_proxy_model.sort(0)
def _set_expected_selection(self):
if self._expected_selection_data is None:
return False
folder_id = self._expected_selection_data["folder_id"]
task_name = self._expected_selection_data["task_name"]
self._expected_selection_data = None
model_folder_id = self._tasks_model.get_last_folder_id()
if folder_id != model_folder_id:
return False
if task_name is not None:
index = self._tasks_model.get_index_by_name(task_name)
if index.isValid():
proxy_index = self._tasks_proxy_model.mapFromSource(index)
self._tasks_view.setCurrentIndex(proxy_index)
self._controller.expected_task_selected(folder_id, task_name)
return True
def _on_expected_selection_change(self, event):
if event["task_selected"] or not event["folder_selected"]:
return
model_folder_id = self._tasks_model.get_last_folder_id()
folder_id = event["folder_id"]
self._expected_selection_data = {
"task_name": event["task_name"],
"folder_id": folder_id,
}
if folder_id != model_folder_id or self._tasks_model.is_refreshing:
return
self._set_expected_selection()
def _get_selected_item_ids(self):
selection_model = self._tasks_view.selectionModel()
for index in selection_model.selectedIndexes():
task_id = index.data(ITEM_ID_ROLE)
task_name = index.data(ITEM_NAME_ROLE)
parent_id = index.data(PARENT_ID_ROLE)
if task_name is not None:
return parent_id, task_id, task_name
return self._selected_folder_id, None, None
def _on_selection_change(self):
# Don't trigger task change during refresh
# - a task was deselected if that happens
# - can cause crash triggered during tasks refreshing
if self._tasks_model.is_refreshing:
return
parent_id, task_id, task_name = self._get_selected_item_ids()
self._controller.set_selected_task(parent_id, task_id, task_name)

View file

@ -5,32 +5,16 @@ from openpype.tools.utils import (
PlaceholderLineEdit,
MessageOverlayObject,
)
from openpype.tools.utils.lib import get_qta_icon_by_name_and_color
from openpype.tools.ayon_utils.widgets import FoldersWidget, TasksWidget
from openpype.tools.ayon_workfiles.control import BaseWorkfileController
from openpype.tools.utils import GoToCurrentButton, RefreshButton
from .side_panel import SidePanelWidget
from .folders_widget import FoldersWidget
from .tasks_widget import TasksWidget
from .files_widget import FilesWidget
from .utils import BaseOverlayFrame
# TODO move to utils
# from openpype.tools.utils.lib import (
# get_refresh_icon, get_go_to_current_icon)
def get_refresh_icon():
return get_qta_icon_by_name_and_color(
"fa.refresh", style.get_default_tools_icon_color()
)
def get_go_to_current_icon():
return get_qta_icon_by_name_and_color(
"fa.arrow-down", style.get_default_tools_icon_color()
)
class InvalidHostOverlay(BaseOverlayFrame):
def __init__(self, parent):
super(InvalidHostOverlay, self).__init__(parent)
@ -80,7 +64,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
self._default_window_flags = flags
self._folder_widget = None
self._folders_widget = None
self._folder_filter_input = None
self._files_widget = None
@ -100,7 +84,9 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
home_body_widget = QtWidgets.QWidget(home_page_widget)
col_1_widget = self._create_col_1_widget(controller, parent)
tasks_widget = TasksWidget(controller, home_body_widget)
tasks_widget = TasksWidget(
controller, home_body_widget, handle_expected_selection=True
)
col_3_widget = self._create_col_3_widget(controller, home_body_widget)
side_panel = SidePanelWidget(controller, home_body_widget)
@ -151,11 +137,11 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
self._on_open_finished
)
controller.register_event_callback(
"controller.refresh.started",
"controller.reset.started",
self._on_controller_refresh_started,
)
controller.register_event_callback(
"controller.refresh.finished",
"controller.reset.finished",
self._on_controller_refresh_finished,
)
@ -188,19 +174,12 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
folder_filter_input = PlaceholderLineEdit(header_widget)
folder_filter_input.setPlaceholderText("Filter folders..")
go_to_current_btn = QtWidgets.QPushButton(header_widget)
go_to_current_btn.setIcon(get_go_to_current_icon())
go_to_current_btn_sp = go_to_current_btn.sizePolicy()
go_to_current_btn_sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum)
go_to_current_btn.setSizePolicy(go_to_current_btn_sp)
go_to_current_btn = GoToCurrentButton(header_widget)
refresh_btn = RefreshButton(header_widget)
refresh_btn = QtWidgets.QPushButton(header_widget)
refresh_btn.setIcon(get_refresh_icon())
refresh_btn_sp = refresh_btn.sizePolicy()
refresh_btn_sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum)
refresh_btn.setSizePolicy(refresh_btn_sp)
folder_widget = FoldersWidget(controller, col_widget)
folder_widget = FoldersWidget(
controller, col_widget, handle_expected_selection=True
)
header_layout = QtWidgets.QHBoxLayout(header_widget)
header_layout.setContentsMargins(0, 0, 0, 0)
@ -218,7 +197,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
refresh_btn.clicked.connect(self._on_refresh_clicked)
self._folder_filter_input = folder_filter_input
self._folder_widget = folder_widget
self._folders_widget = folder_widget
return col_widget
@ -300,7 +279,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
def refresh(self):
"""Trigger refresh of workfiles tool controller."""
self._controller.refresh()
self._controller.reset()
def showEvent(self, event):
super(WorkfilesToolWindow, self).showEvent(event)
@ -338,7 +317,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_filter(text)
self._folders_widget.set_name_filter(text)
def _on_go_to_current_clicked(self):
self._controller.go_to_current_context()
@ -357,6 +336,10 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
if not self._host_is_valid:
return
self._folders_widget.set_project_name(
self._controller.get_current_project_name()
)
def _on_save_as_finished(self, event):
if event["failed"]:
self._overlay_messages_widget.add_message(

View file

@ -1453,7 +1453,7 @@ class BasePublisherController(AbstractPublisherController):
"""
if self._log is None:
self._log = logging.getLogget(self.__class__.__name__)
self._log = logging.getLogger(self.__class__.__name__)
return self._log
@property
@ -1881,10 +1881,19 @@ class PublisherController(BasePublisherController):
self._emit_event("plugins.refresh.finished")
def _collect_creator_items(self):
return {
identifier: CreatorItem.from_creator(creator)
for identifier, creator in self._create_context.creators.items()
}
# TODO add crashed initialization of create plugins to report
output = {}
for identifier, creator in self._create_context.creators.items():
try:
output[identifier] = CreatorItem.from_creator(creator)
except Exception:
self.log.error(
"Failed to create creator item for '%s'",
identifier,
exc_info=True
)
return output
def _reset_instances(self):
"""Reset create instances."""

View file

@ -1,5 +1,7 @@
from qtpy import QtWidgets, QtCore
from openpype import AYON_SERVER_ENABLED
from .border_label_widget import BorderedLabelWidget
from .card_view_widgets import InstanceCardView
@ -35,7 +37,10 @@ class OverviewWidget(QtWidgets.QFrame):
# --- Created Subsets/Instances ---
# Common widget for creation and overview
subset_views_widget = BorderedLabelWidget(
"Subsets to publish", subset_content_widget
"{} to publish".format(
"Products" if AYON_SERVER_ENABLED else "Subsets"
),
subset_content_widget
)
subset_view_cards = InstanceCardView(controller, subset_views_widget)

View file

@ -210,7 +210,9 @@ class CreateBtn(PublishIconBtn):
def __init__(self, parent=None):
icon_path = get_icon_path("create")
super(CreateBtn, self).__init__(icon_path, "Create", parent)
self.setToolTip("Create new subset/s")
self.setToolTip("Create new {}/s".format(
"product" if AYON_SERVER_ENABLED else "subset"
))
self.setLayoutDirection(QtCore.Qt.RightToLeft)
@ -655,7 +657,11 @@ class TasksCombobox(QtWidgets.QComboBox):
self._proxy_model.set_filter_empty(invalid)
if invalid:
self._set_is_valid(False)
self.set_text("< One or more subsets require Task selected >")
self.set_text(
"< One or more {} require Task selected >".format(
"products" if AYON_SERVER_ENABLED else "subsets"
)
)
else:
self.set_text(None)

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
__version__ = "3.17.6-nightly.3"
__version__ = "3.17.7-nightly.1"

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "OpenPype"
version = "3.17.5" # OpenPype
version = "3.17.6" # OpenPype
description = "Open VFX and Animation pipeline with support."
authors = ["OpenPype Team <info@openpype.io>"]
license = "MIT License"

View file

@ -1092,6 +1092,32 @@
}
]
},
"substancepainter": {
"enabled": true,
"label": "Substance Painter",
"icon": "{}/app_icons/substancepainter.png",
"host_name": "substancepainter",
"environment": "{}",
"variants": [
{
"name": "8-2-0",
"label": "8.2",
"executables": {
"windows": [
"C:\\Program Files\\Adobe\\Adobe Substance 3D Painter\\Adobe Substance 3D Painter.exe"
],
"darwin": [],
"linux": []
},
"arguments": {
"windows": [],
"darwin": [],
"linux": []
},
"environment": "{}"
}
]
},
"unreal": {
"enabled": true,
"label": "Unreal Editor",

View file

@ -164,6 +164,8 @@ class ApplicationsSettings(BaseSettingsModel):
default_factory=AppGroupWithPython, title="Adobe After Effects")
celaction: AppGroup = Field(
default_factory=AppGroupWithPython, title="Celaction 2D")
substancepainter: AppGroup = Field(
default_factory=AppGroupWithPython, title="Substance Painter")
unreal: AppGroup = Field(
default_factory=AppGroupWithPython, title="Unreal Editor")
additional_apps: list[AdditionalAppGroup] = Field(

View file

@ -1 +1 @@
__version__ = "0.1.2"
__version__ = "0.1.3"

View file

@ -27,6 +27,26 @@ class ValidateAttributesModel(BaseSettingsModel):
return value
class FamilyMappingItemModel(BaseSettingsModel):
product_types: list[str] = Field(
default_factory=list,
title="Product Types"
)
plugins: list[str] = Field(
default_factory=list,
title="Plugins"
)
class ValidateLoadedPluginModel(BaseSettingsModel):
enabled: bool = Field(title="Enabled")
optional: bool = Field(title="Optional")
family_plugins_mapping: list[FamilyMappingItemModel] = Field(
default_factory=list,
title="Family Plugins Mapping"
)
class BasicValidateModel(BaseSettingsModel):
enabled: bool = Field(title="Enabled")
optional: bool = Field(title="Optional")
@ -44,6 +64,10 @@ class PublishersModel(BaseSettingsModel):
title="Validate Attributes"
)
ValidateLoadedPlugin: ValidateLoadedPluginModel = Field(
default_factory=ValidateLoadedPluginModel,
title="Validate Loaded Plugin"
)
DEFAULT_PUBLISH_SETTINGS = {
"ValidateFrameRange": {
@ -55,4 +79,9 @@ DEFAULT_PUBLISH_SETTINGS = {
"enabled": False,
"attributes": "{}"
},
"ValidateLoadedPlugin": {
"enabled": False,
"optional": True,
"family_plugins_mapping": []
}
}

View file

@ -213,16 +213,16 @@ class ImageIOSettings(BaseSettingsModel):
DEFAULT_IMAGEIO_SETTINGS = {
"viewer": {
"viewerProcess": "sRGB"
"viewerProcess": "sRGB (default)"
},
"baking": {
"viewerProcess": "rec709"
"viewerProcess": "rec709 (default)"
},
"workfile": {
"color_management": "Nuke",
"color_management": "OCIO",
"native_ocio_config": "nuke-default",
"working_space": "linear",
"thumbnail_space": "sRGB",
"working_space": "scene_linear",
"thumbnail_space": "sRGB (default)",
},
"nodes": {
"required_nodes": [
@ -269,7 +269,7 @@ DEFAULT_IMAGEIO_SETTINGS = {
{
"type": "text",
"name": "colorspace",
"text": "linear"
"text": "scene_linear"
},
{
"type": "boolean",
@ -321,7 +321,7 @@ DEFAULT_IMAGEIO_SETTINGS = {
{
"type": "text",
"name": "colorspace",
"text": "linear"
"text": "scene_linear"
},
{
"type": "boolean",
@ -368,7 +368,7 @@ DEFAULT_IMAGEIO_SETTINGS = {
{
"type": "text",
"name": "colorspace",
"text": "sRGB"
"text": "texture_paint"
},
{
"type": "boolean",

View file

@ -1 +1 @@
__version__ = "0.1.4"
__version__ = "0.1.5"

View file

@ -0,0 +1,17 @@
from typing import Type
from ayon_server.addons import BaseServerAddon
from .version import __version__
from .settings import SubstancePainterSettings, DEFAULT_SPAINTER_SETTINGS
class SubstancePainterAddon(BaseServerAddon):
name = "substancepainter"
title = "Substance Painter"
version = __version__
settings_model: Type[SubstancePainterSettings] = SubstancePainterSettings
async def get_default_settings(self):
settings_model_cls = self.get_settings_model()
return settings_model_cls(**DEFAULT_SPAINTER_SETTINGS)

View file

@ -0,0 +1,10 @@
from .main import (
SubstancePainterSettings,
DEFAULT_SPAINTER_SETTINGS,
)
__all__ = (
"SubstancePainterSettings",
"DEFAULT_SPAINTER_SETTINGS",
)

View file

@ -0,0 +1,61 @@
from pydantic import Field, validator
from ayon_server.settings import BaseSettingsModel
from ayon_server.settings.validators import ensure_unique_names
class ImageIOConfigModel(BaseSettingsModel):
override_global_config: bool = Field(
False,
title="Override global OCIO config"
)
filepath: list[str] = Field(
default_factory=list,
title="Config path"
)
class ImageIOFileRuleModel(BaseSettingsModel):
name: str = Field("", title="Rule name")
pattern: str = Field("", title="Regex pattern")
colorspace: str = Field("", title="Colorspace name")
ext: str = Field("", title="File extension")
class ImageIOFileRulesModel(BaseSettingsModel):
activate_host_rules: bool = Field(False)
rules: list[ImageIOFileRuleModel] = Field(
default_factory=list,
title="Rules"
)
@validator("rules")
def validate_unique_outputs(cls, value):
ensure_unique_names(value)
return value
class ImageIOSettings(BaseSettingsModel):
activate_host_color_management: bool = Field(
True, title="Enable Color Management"
)
ocio_config: ImageIOConfigModel = Field(
default_factory=ImageIOConfigModel,
title="OCIO config"
)
file_rules: ImageIOFileRulesModel = Field(
default_factory=ImageIOFileRulesModel,
title="File Rules"
)
DEFAULT_IMAGEIO_SETTINGS = {
"activate_host_color_management": True,
"ocio_config": {
"override_global_config": False,
"filepath": []
},
"file_rules": {
"activate_host_rules": False,
"rules": []
}
}

View file

@ -0,0 +1,26 @@
from pydantic import Field
from ayon_server.settings import BaseSettingsModel
from .imageio import ImageIOSettings, DEFAULT_IMAGEIO_SETTINGS
class ShelvesSettingsModel(BaseSettingsModel):
_layout = "compact"
name: str = Field(title="Name")
value: str = Field(title="Path")
class SubstancePainterSettings(BaseSettingsModel):
imageio: ImageIOSettings = Field(
default_factory=ImageIOSettings,
title="Color Management (ImageIO)"
)
shelves: list[ShelvesSettingsModel] = Field(
default_factory=list,
title="Shelves"
)
DEFAULT_SPAINTER_SETTINGS = {
"imageio": DEFAULT_IMAGEIO_SETTINGS,
"shelves": []
}

View file

@ -0,0 +1 @@
__version__ = "0.1.0"