Merge branch 'develop' into enhancement/OP-6836_Nuke-extract-review-data-mov-read-default

This commit is contained in:
Jakub Ježek 2023-09-25 10:59:24 +02:00 committed by GitHub
commit 0211f3da55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
130 changed files with 6786 additions and 2424 deletions

View file

@ -35,6 +35,10 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
- 3.17.1-nightly.2
- 3.17.1-nightly.1
- 3.17.0
- 3.16.7
- 3.16.7-nightly.2
- 3.16.7-nightly.1
- 3.16.6
@ -131,10 +135,6 @@ body:
- 3.14.10-nightly.5
- 3.14.10-nightly.4
- 3.14.10-nightly.3
- 3.14.10-nightly.2
- 3.14.10-nightly.1
- 3.14.9
- 3.14.9-nightly.5
validations:
required: true
- type: dropdown

View file

@ -1,6 +1,379 @@
# Changelog
## [3.17.0](https://github.com/ynput/OpenPype/tree/3.17.0)
[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.7...3.17.0)
### **🚀 Enhancements**
<details>
<summary>Chore: Remove schema from OpenPype root <a href="https://github.com/ynput/OpenPype/pull/5355">#5355</a></summary>
Remove unused schema directory in root of repository which was moved inside openpype/pipeline/schema.
___
</details>
<details>
<summary>Igniter: Allow custom Qt scale factor rounding policy <a href="https://github.com/ynput/OpenPype/pull/5554">#5554</a></summary>
Do not force `PassThrough` rounding policy if different policy is defined via env variable.
___
</details>
### **🐛 Bug fixes**
<details>
<summary>Chore: Lower urllib3 to support older OpenSSL <a href="https://github.com/ynput/OpenPype/pull/5538">#5538</a></summary>
Lowered `urllib3` to `1.26.16` to support older OpenSSL.
___
</details>
<details>
<summary>Chore: Do not try to add schema to zip files <a href="https://github.com/ynput/OpenPype/pull/5557">#5557</a></summary>
Do not add `schema` folder to zip file. This fixes issue cause by https://github.com/ynput/OpenPype/pull/5355 .
___
</details>
<details>
<summary>Chore: Lower click dependency version <a href="https://github.com/ynput/OpenPype/pull/5629">#5629</a></summary>
Lower click version to support older versions of python.
___
</details>
### **Merged pull requests**
<details>
<summary>Bump certifi from 2023.5.7 to 2023.7.22 <a href="https://github.com/ynput/OpenPype/pull/5351">#5351</a></summary>
Bumps [certifi](https://github.com/certifi/python-certifi) from 2023.5.7 to 2023.7.22.
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/certifi/python-certifi/commit/8fb96ed81f71e7097ed11bc4d9b19afd7ea5c909"><code>8fb96ed</code></a> 2023.07.22</li>
<li><a href="https://github.com/certifi/python-certifi/commit/afe77220e0eaa722593fc5d294213ff5275d1b40"><code>afe7722</code></a> Bump actions/setup-python from 4.6.1 to 4.7.0 (<a href="https://redirect.github.com/certifi/python-certifi/issues/230">#230</a>)</li>
<li><a href="https://github.com/certifi/python-certifi/commit/2038739ad56abec7aaddfa90ad2ce6b3ed7f5c7b"><code>2038739</code></a> Bump dessant/lock-threads from 3.0.0 to 4.0.1 (<a href="https://redirect.github.com/certifi/python-certifi/issues/229">#229</a>)</li>
<li><a href="https://github.com/certifi/python-certifi/commit/44df761f4c09d19f32b3cc09208a739043a5e25b"><code>44df761</code></a> Hash pin Actions and enable dependabot (<a href="https://redirect.github.com/certifi/python-certifi/issues/228">#228</a>)</li>
<li>See full diff in <a href="https://github.com/certifi/python-certifi/compare/2023.05.07...2023.07.22">compare view</a></li>
</ul>
</details>
<br />
[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=certifi&package-manager=pip&previous-version=2023.5.7&new-version=2023.7.22)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
You can trigger a rebase of this PR by commenting `@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/ynput/OpenPype/network/alerts).
</details>
> **Note**
> Automatic rebases have been disabled on this pull request as it has been open for over 30 days.
___
</details>
## [3.16.7](https://github.com/ynput/OpenPype/tree/3.16.7)
[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.6...3.16.7)
### **🆕 New features**
<details>
<summary>Maya: Extract active view as thumbnail when no thumbnail set <a href="https://github.com/ynput/OpenPype/pull/5426">#5426</a></summary>
This sets the Maya instance's thumbnail to the current active view if no thumbnail was set yet.
___
</details>
<details>
<summary>Maya: Implement USD publish and load using native `mayaUsdPlugin` <a href="https://github.com/ynput/OpenPype/pull/5573">#5573</a></summary>
Implement Creator and Loaders for extraction and loading of USD files using Maya's own `mayaUsdPlugin`.Also adds support to load a `usd` file into an Arnold Standin (`aiStandin`) and assigning looks to it.
___
</details>
<details>
<summary>AYON: Ignore separated modules <a href="https://github.com/ynput/OpenPype/pull/5619">#5619</a></summary>
Do not load already separated modules from default directory.
___
</details>
### **🚀 Enhancements**
<details>
<summary>Maya: Reduce amount of code for Collect Looks <a href="https://github.com/ynput/OpenPype/pull/5253">#5253</a></summary>
- Refactor `get_file_node_files` because popping from `paths` by index should have been done in reversed order anyway. It's now changed to not need popping at all.
- Removed unused `RENDERER_NODE_TYPES` and if-branch which collected `node_attrs` list which was unused + collected members which was also done outside of the if branch and thus generated no extra data.
- Collected all materials from look set attributes at once instead of multiple queries
- Collected all file nodes in history from a single query instead of per type
- Restructured assignment of `instance.data["resources"]` to be more readable
- Cached `PXR_NODES` only ones (Note: plugin load is checked on discovery of the collect look plugin) instead of querying plugin load and its nodes per file node per attribute
- Removed some debug logs or combined some messages
___
</details>
<details>
<summary>AYON: Mark deprecated settings in Maya <a href="https://github.com/ynput/OpenPype/pull/5627">#5627</a></summary>
Added deprecated info to docstrings of maya colormanagement settings.Resolves: https://github.com/ynput/OpenPype/issues/5556
___
</details>
<details>
<summary>Max: switching versions of maxScene maintain parentage/links with the loaders <a href="https://github.com/ynput/OpenPype/pull/5424">#5424</a></summary>
When using scene inventory to manage or update the version of the loading objects, the linked modifiers or parentage of the objects would be kept.Meanwhile, loaded objects from all loaders no longer parented to the container with OP Data.
___
</details>
<details>
<summary>3ds max: small tweaks to obj extractor and model publishing flow <a href="https://github.com/ynput/OpenPype/pull/5605">#5605</a></summary>
There migh be situation where OBJ Extractor passes without failure, but no obj file is produced. This is adding simple check directly into the extractor to catch it earlier then in the integration phase. Also switched `Validate USD Plugin` to optional, because it was always run no matter if the Extract USD was enabled or not, hindering testing (and publishing).
___
</details>
<details>
<summary>TVPaint: Plugin can be reopened <a href="https://github.com/ynput/OpenPype/pull/5610">#5610</a></summary>
TVPaint plugin can be reopened.
___
</details>
<details>
<summary>Maya: Remove context prompt <a href="https://github.com/ynput/OpenPype/pull/5632">#5632</a></summary>
More of a plea than a PR, but could we please remove the context prompt in Maya when switching tasks?
___
</details>
<details>
<summary>General: Create a desktop icon is checked <a href="https://github.com/ynput/OpenPype/pull/5636">#5636</a></summary>
In OP Installer `Create a desktop icon` is checked by default.
___
</details>
### **🐛 Bug fixes**
<details>
<summary>Maya: Extract look is not AYON compatible - OP-5375 <a href="https://github.com/ynput/OpenPype/pull/5341">#5341</a></summary>
The textures that would use hardlinking are going through texture processors. Currently all texture processors are hardcoded to copy texture instead of respecting the settings of forcing to copy.The texture processors were last modified 4 months ago, so effectively all clients that are on any pipeline updated in the last 4 months wont be utilizing hardlinking at all, since the hardcoded texture processors will copy texture no matter the OS.This opts for completely disabling the hardlinking feature, while we figure out what to do about it.
___
</details>
<details>
<summary>Maya: Multiverse USD Override inherit from correct new style creator <a href="https://github.com/ynput/OpenPype/pull/5566">#5566</a></summary>
Fix Creator for Multiverse USD Override by inheriting from correct new style creator class type
___
</details>
<details>
<summary>Max: Bug Fix Alembic Loaders with Ornatrix <a href="https://github.com/ynput/OpenPype/pull/5434">#5434</a></summary>
Bugfix the alembic loader with both ornatrix alembic and max alembic supportsAdd the ornatrix alembic loaders for loading the alembic with Ornatrix-related modifiers.
___
</details>
<details>
<summary>AYON: Avoid creation of duplicated links <a href="https://github.com/ynput/OpenPype/pull/5593">#5593</a></summary>
Handle cases when an existing link should be recreated and do not create the same link multitple times during single publishing.
___
</details>
<details>
<summary>Extract Review: Multilayer specification for ffmpeg <a href="https://github.com/ynput/OpenPype/pull/5613">#5613</a></summary>
Extract review is specifying layer name when exr is multilayer.
___
</details>
<details>
<summary>Fussion: added support for Fusion 17 <a href="https://github.com/ynput/OpenPype/pull/5614">#5614</a></summary>
Fusion 17 still uses Python 3.6 which causes issues with some our delivered libraries. Vendorized necessary set for Python 3.6
___
</details>
<details>
<summary>Publisher: Fix screenshot widget <a href="https://github.com/ynput/OpenPype/pull/5615">#5615</a></summary>
Use correct super method name.EDITED:Removed fade animation which is not triggered at some cases, e.g. in Nuke the animation does not start. I do expect that is caused by `exec_` on the dialog, which blocks event processing to the animation, even when I've added the window as parent it still didn't trigger registered callback.Modified how the "empty" space is not filled by using paths instead of clear mode on painter. Added render hints to add antialiasing.
___
</details>
<details>
<summary>Photoshop: auto_images without alpha will not fail <a href="https://github.com/ynput/OpenPype/pull/5620">#5620</a></summary>
ExtractReview caused issue on `auto_image` instance without alpha channel, this fixes it.
___
</details>
<details>
<summary>Fix - _id key used instead of id in get_last_version_by_subset_name <a href="https://github.com/ynput/OpenPype/pull/5626">#5626</a></summary>
Just 'id' is not returned because value in fields. Caused KeyError.
___
</details>
<details>
<summary>Bugfix: create symlinks for ssl libs on Centos 7 <a href="https://github.com/ynput/OpenPype/pull/5633">#5633</a></summary>
Docker build was missing `libssl.1.1.so` and `libcrypto.1.1.so` symlinks needed by the executable itself, because Python is now explicitly built with OpenSSL 1.1.1
___
</details>
### **📃 Documentation**
<details>
<summary>Documentation/local settings <a href="https://github.com/ynput/OpenPype/pull/5102">#5102</a></summary>
I completed the "Working with local settings" page. I updated the screenshot, wrote an explanation for each empty category, and if available, linked the more detailed pages already existing. I also added the "Environments" category.
___
</details>
## [3.16.6](https://github.com/ynput/OpenPype/tree/3.16.6)

View file

@ -34,7 +34,11 @@ def _get_qt_app():
if attr is not None:
QtWidgets.QApplication.setAttribute(attr)
if hasattr(QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy"):
policy = os.getenv("QT_SCALE_FACTOR_ROUNDING_POLICY")
if (
hasattr(QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy")
and not policy
):
QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy(
QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)

View file

@ -589,7 +589,7 @@ class BootstrapRepos:
self.registry = OpenPypeSettingsRegistry()
self.zip_filter = [".pyc", "__pycache__"]
self.openpype_filter = [
"openpype", "schema", "LICENSE"
"openpype", "LICENSE"
]
# dummy progress reporter

View file

@ -36,7 +36,7 @@ WizardStyle=modern
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
[InstallDelete]
; clean everything in previous installation folder
@ -53,4 +53,3 @@ Name: "{autodesktop}\{#MyAppName} {#AppVer}"; Filename: "{app}\openpype_gui.exe"
[Run]
Filename: "{app}\openpype_gui.exe"; Description: "{cm:LaunchProgram,OpenPype}"; Flags: nowait postinstall skipifsilent

View file

@ -235,6 +235,8 @@ def convert_v4_project_to_v3(project):
new_task_types = {}
for task_type in task_types:
name = task_type.pop("name")
# Change 'shortName' to 'short_name'
task_type["short_name"] = task_type.pop("shortName", None)
new_task_types[name] = task_type
config["tasks"] = new_task_types

View file

@ -3,9 +3,7 @@ from .pipeline import (
ls,
imprint_container,
parse_container,
list_instances,
remove_instance
parse_container
)
from .lib import (
@ -22,6 +20,7 @@ from .menu import launch_openpype_menu
__all__ = [
# pipeline
"FusionHost",
"ls",
"imprint_container",
@ -32,6 +31,7 @@ __all__ = [
"update_frame_range",
"set_asset_framerange",
"get_current_comp",
"get_bmd_library",
"comp_lock_and_undo_chunk",
# menu

View file

@ -181,80 +181,6 @@ def validate_comp_prefs(comp=None, force_repair=False):
dialog.setStyleSheet(load_stylesheet())
def switch_item(container,
asset_name=None,
subset_name=None,
representation_name=None):
"""Switch container asset, subset or representation of a container by name.
It'll always switch to the latest version - of course a different
approach could be implemented.
Args:
container (dict): data of the item to switch with
asset_name (str): name of the asset
subset_name (str): name of the subset
representation_name (str): name of the representation
Returns:
dict
"""
if all(not x for x in [asset_name, subset_name, representation_name]):
raise ValueError("Must have at least one change provided to switch.")
# Collect any of current asset, subset and representation if not provided
# so we can use the original name from those.
project_name = get_current_project_name()
if any(not x for x in [asset_name, subset_name, representation_name]):
repre_id = container["representation"]
representation = get_representation_by_id(project_name, repre_id)
repre_parent_docs = get_representation_parents(
project_name, representation)
if repre_parent_docs:
version, subset, asset, _ = repre_parent_docs
else:
version = subset = asset = None
if asset_name is None:
asset_name = asset["name"]
if subset_name is None:
subset_name = subset["name"]
if representation_name is None:
representation_name = representation["name"]
# Find the new one
asset = get_asset_by_name(project_name, asset_name, fields=["_id"])
assert asset, ("Could not find asset in the database with the name "
"'%s'" % asset_name)
subset = get_subset_by_name(
project_name, subset_name, asset["_id"], fields=["_id"]
)
assert subset, ("Could not find subset in the database with the name "
"'%s'" % subset_name)
version = get_last_version_by_subset_id(
project_name, subset["_id"], fields=["_id"]
)
assert version, "Could not find a version for {}.{}".format(
asset_name, subset_name
)
representation = get_representation_by_name(
project_name, representation_name, version["_id"]
)
assert representation, ("Could not find representation in the database "
"with the name '%s'" % representation_name)
switch_container(container, representation)
return representation
@contextlib.contextmanager
def maintained_selection(comp=None):
"""Reset comp selection from before the context after the context"""

View file

@ -287,49 +287,6 @@ def parse_container(tool):
return container
# TODO: Function below is currently unused prototypes
def list_instances(creator_id=None):
"""Return created instances in current workfile which will be published.
Returns:
(list) of dictionaries matching instances format
"""
comp = get_current_comp()
tools = comp.GetToolList(False).values()
instance_signature = {
"id": "pyblish.avalon.instance",
"identifier": creator_id
}
instances = []
for tool in tools:
data = tool.GetData('openpype')
if not isinstance(data, dict):
continue
if data.get("id") != instance_signature["id"]:
continue
if creator_id and data.get("identifier") != creator_id:
continue
instances.append(tool)
return instances
# TODO: Function below is currently unused prototypes
def remove_instance(instance):
"""Remove instance from current workfile.
Args:
instance (dict): instance representation from subsetmanager model
"""
# Assume instance is a Fusion tool directly
instance["tool"].Delete()
class FusionEventThread(QtCore.QThread):
"""QThread which will periodically ping Fusion app for any events.
The fusion.UIManager must be set up to be notified of events before they'll

View file

@ -649,3 +649,101 @@ def get_color_management_preferences():
"display": hou.Color.ocio_defaultDisplay(),
"view": hou.Color.ocio_defaultView()
}
def get_obj_node_output(obj_node):
"""Find output node.
If the node has any output node return the
output node with the minimum `outputidx`.
When no output is present return the node
with the display flag set. If no output node is
detected then None is returned.
Arguments:
node (hou.Node): The node to retrieve a single
the output node for.
Returns:
Optional[hou.Node]: The child output node.
"""
outputs = obj_node.subnetOutputs()
if not outputs:
return
elif len(outputs) == 1:
return outputs[0]
else:
return min(outputs,
key=lambda node: node.evalParm('outputidx'))
def get_output_children(output_node, include_sops=True):
"""Recursively return a list of all output nodes
contained in this node including this node.
It works in a similar manner to output_node.allNodes().
"""
out_list = [output_node]
if output_node.childTypeCategory() == hou.objNodeTypeCategory():
for child in output_node.children():
out_list += get_output_children(child, include_sops=include_sops)
elif include_sops and \
output_node.childTypeCategory() == hou.sopNodeTypeCategory():
out = get_obj_node_output(output_node)
if out:
out_list += [out]
return out_list
def get_resolution_from_doc(doc):
"""Get resolution from the given asset document. """
if not doc or "data" not in doc:
print("Entered document is not valid. \"{}\"".format(str(doc)))
return None
resolution_width = doc["data"].get("resolutionWidth")
resolution_height = doc["data"].get("resolutionHeight")
# Make sure both width and height are set
if resolution_width is None or resolution_height is None:
print("No resolution information found for \"{}\"".format(doc["name"]))
return None
return int(resolution_width), int(resolution_height)
def set_camera_resolution(camera, asset_doc=None):
"""Apply resolution to camera from asset document of the publish"""
if not asset_doc:
asset_doc = get_current_project_asset()
resolution = get_resolution_from_doc(asset_doc)
if resolution:
print("Setting camera resolution: {} -> {}x{}".format(
camera.name(), resolution[0], resolution[1]
))
camera.parm("resx").set(resolution[0])
camera.parm("resy").set(resolution[1])
def get_camera_from_container(container):
"""Get camera from container node. """
cameras = container.recursiveGlob(
"*",
filter=hou.nodeTypeFilter.ObjCamera,
include_subnets=False
)
assert len(cameras) == 1, "Camera instance must have only one camera"
return cameras[0]

View file

@ -14,6 +14,7 @@ import pyblish.api
from openpype.pipeline import (
register_creator_plugin_path,
register_loader_plugin_path,
register_inventory_action_path,
AVALON_CONTAINER_ID,
)
from openpype.pipeline.load import any_outdated_containers
@ -55,6 +56,7 @@ class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
pyblish.api.register_plugin_path(PUBLISH_PATH)
register_loader_plugin_path(LOAD_PATH)
register_creator_plugin_path(CREATE_PATH)
register_inventory_action_path(INVENTORY_PATH)
log.info("Installing callbacks ... ")
# register_event_callback("init", on_init)

View file

@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
"""Creator for Unreal Static Meshes."""
from openpype.hosts.houdini.api import plugin
from openpype.lib import BoolDef, EnumDef
import hou
class CreateStaticMesh(plugin.HoudiniCreator):
"""Static Meshes as FBX. """
identifier = "io.openpype.creators.houdini.staticmesh.fbx"
label = "Static Mesh (FBX)"
family = "staticMesh"
icon = "fa5s.cubes"
default_variants = ["Main"]
def create(self, subset_name, instance_data, pre_create_data):
instance_data.update({"node_type": "filmboxfbx"})
instance = super(CreateStaticMesh, self).create(
subset_name,
instance_data,
pre_create_data)
# get the created rop node
instance_node = hou.node(instance.get("instance_node"))
# prepare parms
output_path = hou.text.expandString(
"$HIP/pyblish/{}.fbx".format(subset_name)
)
parms = {
"startnode": self.get_selection(),
"sopoutput": output_path,
# vertex cache format
"vcformat": pre_create_data.get("vcformat"),
"convertunits": pre_create_data.get("convertunits"),
# set render range to use frame range start-end frame
"trange": 1,
"createsubnetroot": pre_create_data.get("createsubnetroot")
}
# set parms
instance_node.setParms(parms)
# Lock any parameters in this list
to_lock = ["family", "id"]
self.lock_parameters(instance_node, to_lock)
def get_network_categories(self):
return [
hou.ropNodeTypeCategory(),
hou.sopNodeTypeCategory()
]
def get_pre_create_attr_defs(self):
"""Add settings for users. """
attrs = super(CreateStaticMesh, self).get_pre_create_attr_defs()
createsubnetroot = BoolDef("createsubnetroot",
tooltip="Create an extra root for the "
"Export node when it's a "
"subnetwork. This causes the "
"exporting subnetwork node to be "
"represented in the FBX file.",
default=False,
label="Create Root for Subnet")
vcformat = EnumDef("vcformat",
items={
0: "Maya Compatible (MC)",
1: "3DS MAX Compatible (PC2)"
},
default=0,
label="Vertex Cache Format")
convert_units = BoolDef("convertunits",
tooltip="When on, the FBX is converted"
"from the current Houdini "
"system units to the native "
"FBX unit of centimeters.",
default=False,
label="Convert Units")
return attrs + [createsubnetroot, vcformat, convert_units]
def get_dynamic_data(
self, variant, task_name, asset_doc, project_name, host_name, instance
):
"""
The default subset name templates for Unreal include {asset} and thus
we should pass that along as dynamic data.
"""
dynamic_data = super(CreateStaticMesh, self).get_dynamic_data(
variant, task_name, asset_doc, project_name, host_name, instance
)
dynamic_data["asset"] = asset_doc["name"]
return dynamic_data
def get_selection(self):
"""Selection Logic.
how self.selected_nodes should be processed to get
the desirable node from selection.
Returns:
str : node path
"""
selection = ""
if self.selected_nodes:
selected_node = self.selected_nodes[0]
# Accept sop level nodes (e.g. /obj/geo1/box1)
if isinstance(selected_node, hou.SopNode):
selection = selected_node.path()
self.log.debug(
"Valid SopNode selection, 'Export' in filmboxfbx"
" will be set to '%s'.", selected_node
)
# Accept object level nodes (e.g. /obj/geo1)
elif isinstance(selected_node, hou.ObjNode):
selection = selected_node.path()
self.log.debug(
"Valid ObjNode selection, 'Export' in filmboxfbx "
"will be set to the child path '%s'.", selection
)
else:
self.log.debug(
"Selection isn't valid. 'Export' in "
"filmboxfbx will be empty."
)
else:
self.log.debug(
"No Selection. 'Export' in filmboxfbx will be empty."
)
return selection

View file

@ -0,0 +1,26 @@
from openpype.pipeline import InventoryAction
from openpype.hosts.houdini.api.lib import (
get_camera_from_container,
set_camera_resolution
)
from openpype.pipeline.context_tools import get_current_project_asset
class SetCameraResolution(InventoryAction):
label = "Set Camera Resolution"
icon = "desktop"
color = "orange"
@staticmethod
def is_compatible(container):
return (
container.get("loader") == "CameraLoader"
)
def process(self, containers):
asset_doc = get_current_project_asset()
for container in containers:
node = container["node"]
camera = get_camera_from_container(node)
set_camera_resolution(camera, asset_doc)

View file

@ -4,6 +4,13 @@ from openpype.pipeline import (
)
from openpype.hosts.houdini.api import pipeline
from openpype.hosts.houdini.api.lib import (
set_camera_resolution,
get_camera_from_container
)
import hou
ARCHIVE_EXPRESSION = ('__import__("_alembic_hom_extensions")'
'.alembicGetCameraDict')
@ -25,7 +32,15 @@ def transfer_non_default_values(src, dest, ignore=None):
channel expression and ignore certain Parm types.
"""
import hou
ignore_types = {
hou.parmTemplateType.Toggle,
hou.parmTemplateType.Menu,
hou.parmTemplateType.Button,
hou.parmTemplateType.FolderSet,
hou.parmTemplateType.Separator,
hou.parmTemplateType.Label,
}
src.updateParmStates()
@ -62,14 +77,6 @@ def transfer_non_default_values(src, dest, ignore=None):
continue
# Ignore folders, separators, etc.
ignore_types = {
hou.parmTemplateType.Toggle,
hou.parmTemplateType.Menu,
hou.parmTemplateType.Button,
hou.parmTemplateType.FolderSet,
hou.parmTemplateType.Separator,
hou.parmTemplateType.Label,
}
if parm.parmTemplate().type() in ignore_types:
continue
@ -90,13 +97,8 @@ class CameraLoader(load.LoaderPlugin):
def load(self, context, name=None, namespace=None, data=None):
import os
import hou
# Format file name, Houdini only wants forward slashes
file_path = self.filepath_from_context(context)
file_path = os.path.normpath(file_path)
file_path = file_path.replace("\\", "/")
file_path = self.filepath_from_context(context).replace("\\", "/")
# Get the root node
obj = hou.node("/obj")
@ -106,19 +108,21 @@ class CameraLoader(load.LoaderPlugin):
node_name = "{}_{}".format(namespace, name) if namespace else name
# Create a archive node
container = self.create_and_connect(obj, "alembicarchive", node_name)
node = self.create_and_connect(obj, "alembicarchive", node_name)
# TODO: add FPS of project / asset
container.setParms({"fileName": file_path,
"channelRef": True})
node.setParms({"fileName": file_path, "channelRef": True})
# Apply some magic
container.parm("buildHierarchy").pressButton()
container.moveToGoodPosition()
node.parm("buildHierarchy").pressButton()
node.moveToGoodPosition()
# Create an alembic xform node
nodes = [container]
nodes = [node]
camera = get_camera_from_container(node)
self._match_maya_render_mask(camera)
set_camera_resolution(camera, asset_doc=context["asset"])
self[:] = nodes
return pipeline.containerise(node_name,
@ -143,14 +147,14 @@ class CameraLoader(load.LoaderPlugin):
# Store the cam temporarily next to the Alembic Archive
# so that we can preserve parm values the user set on it
# after build hierarchy was triggered.
old_camera = self._get_camera(node)
old_camera = get_camera_from_container(node)
temp_camera = old_camera.copyTo(node.parent())
# Rebuild
node.parm("buildHierarchy").pressButton()
# Apply values to the new camera
new_camera = self._get_camera(node)
new_camera = get_camera_from_container(node)
transfer_non_default_values(temp_camera,
new_camera,
# The hidden uniform scale attribute
@ -158,6 +162,9 @@ class CameraLoader(load.LoaderPlugin):
# "icon_scale" just skip that completely
ignore={"scale"})
self._match_maya_render_mask(new_camera)
set_camera_resolution(new_camera)
temp_camera.destroy()
def remove(self, container):
@ -165,15 +172,6 @@ class CameraLoader(load.LoaderPlugin):
node = container["node"]
node.destroy()
def _get_camera(self, node):
import hou
cameras = node.recursiveGlob("*",
filter=hou.nodeTypeFilter.ObjCamera,
include_subnets=False)
assert len(cameras) == 1, "Camera instance must have only one camera"
return cameras[0]
def create_and_connect(self, node, node_type, name=None):
"""Create a node within a node which and connect it to the input
@ -194,5 +192,20 @@ class CameraLoader(load.LoaderPlugin):
new_node.moveToGoodPosition()
return new_node
def switch(self, container, representation):
self.update(container, representation)
def _match_maya_render_mask(self, camera):
"""Workaround to match Maya render mask in Houdini"""
# print("Setting match maya render mask ")
parm = camera.parm("aperture")
expression = parm.expression()
expression = expression.replace("return ", "aperture = ")
expression += """
# Match maya render mask (logic from Houdini's own FBX importer)
node = hou.pwd()
resx = node.evalParm('resx')
resy = node.evalParm('resy')
aspect = node.evalParm('aspect')
aperture *= min(1, (resx / resy * aspect) / 1.5)
return aperture
"""
parm.setExpression(expression, language=hou.exprLanguage.Python)

View file

@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
"""Fbx Loader for houdini. """
from openpype.pipeline import (
load,
get_representation_path,
)
from openpype.hosts.houdini.api import pipeline
class FbxLoader(load.LoaderPlugin):
"""Load fbx files. """
label = "Load FBX"
icon = "code-fork"
color = "orange"
order = -10
families = ["staticMesh", "fbx"]
representations = ["fbx"]
def load(self, context, name=None, namespace=None, data=None):
# get file path from context
file_path = self.filepath_from_context(context)
file_path = file_path.replace("\\", "/")
# get necessary data
namespace, node_name = self.get_node_name(context, name, namespace)
# create load tree
nodes = self.create_load_node_tree(file_path, node_name, name)
self[:] = nodes
# Call containerise function which does some automations for you
# like moving created nodes to the AVALON_CONTAINERS subnetwork
containerised_nodes = pipeline.containerise(
node_name,
namespace,
nodes,
context,
self.__class__.__name__,
suffix="",
)
return containerised_nodes
def update(self, container, representation):
node = container["node"]
try:
file_node = next(
n for n in node.children() if n.type().name() == "file"
)
except StopIteration:
self.log.error("Could not find node of type `file`")
return
# Update the file path from representation
file_path = get_representation_path(representation)
file_path = file_path.replace("\\", "/")
file_node.setParms({"file": file_path})
# Update attribute
node.setParms({"representation": str(representation["_id"])})
def remove(self, container):
node = container["node"]
node.destroy()
def switch(self, container, representation):
self.update(container, representation)
def get_node_name(self, context, name=None, namespace=None):
"""Define node name."""
if not namespace:
namespace = context["asset"]["name"]
if namespace:
node_name = "{}_{}".format(namespace, name)
else:
node_name = name
return namespace, node_name
def create_load_node_tree(self, file_path, node_name, subset_name):
"""Create Load network.
you can start building your tree at any obj level.
it'll be much easier to build it in the root obj level.
Afterwards, your tree will be automatically moved to
'/obj/AVALON_CONTAINERS' subnetwork.
"""
import hou
# Get the root obj level
obj = hou.node("/obj")
# Create a new obj geo node
parent_node = obj.createNode("geo", node_name=node_name)
# In older houdini,
# when reating a new obj geo node, a default file node will be
# automatically created.
# so, we will delete it if exists.
file_node = parent_node.node("file1")
if file_node:
file_node.destroy()
# Create a new file node
file_node = parent_node.createNode("file", node_name=node_name)
file_node.setParms({"file": file_path})
# Create attribute delete
attribdelete_name = "attribdelete_{}".format(subset_name)
attribdelete = parent_node.createNode("attribdelete",
node_name=attribdelete_name)
attribdelete.setParms({"ptdel": "fbx_*"})
attribdelete.setInput(0, file_node)
# Create a Null node
null_name = "OUT_{}".format(subset_name)
null = parent_node.createNode("null", node_name=null_name)
null.setInput(0, attribdelete)
# Ensure display flag is on the file_node input node and not on the OUT
# node to optimize "debug" displaying in the viewport.
file_node.setDisplayFlag(True)
# Set new position for children nodes
parent_node.layoutChildren()
# Return all the nodes
return [parent_node, file_node, attribdelete, null]

View file

@ -14,7 +14,8 @@ class CollectOutputSOPPath(pyblish.api.InstancePlugin):
"imagesequence",
"usd",
"usdrender",
"redshiftproxy"
"redshiftproxy",
"staticMesh"
]
hosts = ["houdini"]
@ -59,6 +60,10 @@ class CollectOutputSOPPath(pyblish.api.InstancePlugin):
elif node_type == "Redshift_Proxy_Output":
out_node = node.parm("RS_archive_sopPath").evalAsNode()
elif node_type == "filmboxfbx":
out_node = node.parm("startnode").evalAsNode()
else:
raise KnownPublishError(
"ROP node type '{}' is not supported.".format(node_type)

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
"""Collector for staticMesh types. """
import pyblish.api
class CollectStaticMeshType(pyblish.api.InstancePlugin):
"""Collect data type for fbx instance."""
hosts = ["houdini"]
families = ["staticMesh"]
label = "Collect type of staticMesh"
order = pyblish.api.CollectorOrder
def process(self, instance):
if instance.data["creator_identifier"] == "io.openpype.creators.houdini.staticmesh.fbx": # noqa: E501
# Marking this instance as FBX triggers the FBX extractor.
instance.data["families"] += ["fbx"]

View file

@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
"""Fbx Extractor for houdini. """
import os
import pyblish.api
from openpype.pipeline import publish
from openpype.hosts.houdini.api.lib import render_rop
import hou
class ExtractFBX(publish.Extractor):
label = "Extract FBX"
families = ["fbx"]
hosts = ["houdini"]
order = pyblish.api.ExtractorOrder + 0.1
def process(self, instance):
# get rop node
ropnode = hou.node(instance.data.get("instance_node"))
output_file = ropnode.evalParm("sopoutput")
# get staging_dir and file_name
staging_dir = os.path.normpath(os.path.dirname(output_file))
file_name = os.path.basename(output_file)
# render rop
self.log.debug("Writing FBX '%s' to '%s'", file_name, staging_dir)
render_rop(ropnode)
# prepare representation
representation = {
"name": "fbx",
"ext": "fbx",
"files": file_name,
"stagingDir": staging_dir
}
# A single frame may also be rendered without start/end frame.
if "frameStart" in instance.data and "frameEnd" in instance.data:
representation["frameStart"] = instance.data["frameStart"]
representation["frameEnd"] = instance.data["frameEnd"]
# set value type for 'representations' key to list
if "representations" not in instance.data:
instance.data["representations"] = []
# update instance data
instance.data["stagingDir"] = staging_dir
instance.data["representations"].append(representation)

View file

@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
import pyblish.api
from openpype.pipeline import PublishValidationError
from openpype.hosts.houdini.api.action import (
SelectInvalidAction,
SelectROPAction,
)
from openpype.hosts.houdini.api.lib import get_obj_node_output
import hou
class ValidateFBXOutputNode(pyblish.api.InstancePlugin):
"""Validate the instance Output Node.
This will ensure:
- The Output Node Path is set.
- The Output Node Path refers to an existing object.
- The Output Node is a Sop or Obj node.
- The Output Node has geometry data.
- The Output Node doesn't include invalid primitive types.
"""
order = pyblish.api.ValidatorOrder
families = ["fbx"]
hosts = ["houdini"]
label = "Validate FBX Output Node"
actions = [SelectROPAction, SelectInvalidAction]
def process(self, instance):
invalid = self.get_invalid(instance)
if invalid:
nodes = [n.path() for n in invalid]
raise PublishValidationError(
"See log for details. "
"Invalid nodes: {0}".format(nodes),
title="Invalid output node(s)"
)
@classmethod
def get_invalid(cls, instance):
output_node = instance.data.get("output_node")
# Check if The Output Node Path is set and
# refers to an existing object.
if output_node is None:
rop_node = hou.node(instance.data["instance_node"])
cls.log.error(
"Output node in '%s' does not exist. "
"Ensure a valid output path is set.", rop_node.path()
)
return [rop_node]
# Check if the Output Node is a Sop or an Obj node
# also, list all sop output nodes inside as well as
# invalid empty nodes.
all_out_sops = []
invalid = []
# if output_node is an ObjSubnet or an ObjNetwork
if output_node.childTypeCategory() == hou.objNodeTypeCategory():
for node in output_node.allSubChildren():
if node.type().name() == "geo":
out = get_obj_node_output(node)
if out:
all_out_sops.append(out)
else:
invalid.append(node) # empty_objs
cls.log.error(
"Geo Obj Node '%s' is empty!",
node.path()
)
if not all_out_sops:
invalid.append(output_node) # empty_objs
cls.log.error(
"Output Node '%s' is empty!",
node.path()
)
# elif output_node is an ObjNode
elif output_node.type().name() == "geo":
out = get_obj_node_output(output_node)
if out:
all_out_sops.append(out)
else:
invalid.append(node) # empty_objs
cls.log.error(
"Output Node '%s' is empty!",
node.path()
)
# elif output_node is a SopNode
elif output_node.type().category().name() == "Sop":
all_out_sops.append(output_node)
# Then it's a wrong node type
else:
cls.log.error(
"Output node %s is not a SOP or OBJ Geo or OBJ SubNet node. "
"Instead found category type: %s %s",
output_node.path(), output_node.type().category().name(),
output_node.type().name()
)
return [output_node]
# Check if all output sop nodes have geometry
# and don't contain invalid prims
invalid_prim_types = ["VDB", "Volume"]
for sop_node in all_out_sops:
# Empty Geometry test
if not hasattr(sop_node, "geometry"):
invalid.append(sop_node) # empty_geometry
cls.log.error(
"Sop node '%s' doesn't include any prims.",
sop_node.path()
)
continue
frame = instance.data.get("frameStart", 0)
geo = sop_node.geometryAtFrame(frame)
if len(geo.iterPrims()) == 0:
invalid.append(sop_node) # empty_geometry
cls.log.error(
"Sop node '%s' doesn't include any prims.",
sop_node.path()
)
continue
# Invalid Prims test
for prim_type in invalid_prim_types:
if geo.countPrimType(prim_type) > 0:
invalid.append(sop_node) # invalid_prims
cls.log.error(
"Sop node '%s' includes invalid prims of type '%s'.",
sop_node.path(), prim_type
)
if invalid:
return invalid

View file

@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""Validator for correct naming of Static Meshes."""
import pyblish.api
from openpype.pipeline import (
PublishValidationError,
OptionalPyblishPluginMixin
)
from openpype.pipeline.publish import ValidateContentsOrder
from openpype.hosts.houdini.api.action import SelectInvalidAction
from openpype.hosts.houdini.api.lib import get_output_children
class ValidateMeshIsStatic(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Validate mesh is static.
It checks if output node is time dependent.
"""
families = ["staticMesh"]
hosts = ["houdini"]
label = "Validate Mesh is Static"
order = ValidateContentsOrder + 0.1
actions = [SelectInvalidAction]
def process(self, instance):
invalid = self.get_invalid(instance)
if invalid:
nodes = [n.path() for n in invalid]
raise PublishValidationError(
"See log for details. "
"Invalid nodes: {0}".format(nodes)
)
@classmethod
def get_invalid(cls, instance):
invalid = []
output_node = instance.data.get("output_node")
if output_node is None:
cls.log.debug(
"No Output Node, skipping check.."
)
return
all_outputs = get_output_children(output_node)
for output in all_outputs:
if output.isTimeDependent():
invalid.append(output)
cls.log.error(
"Output node '%s' is time dependent.",
output.path()
)
return invalid

View file

@ -24,7 +24,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin):
order = pyblish.api.ValidatorOrder
families = ["pointcache", "vdbcache"]
hosts = ["houdini"]
label = "Validate Output Node"
label = "Validate Output Node (SOP)"
actions = [SelectROPAction, SelectInvalidAction]
def process(self, instance):

View file

@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
"""Validator for correct naming of Static Meshes."""
import pyblish.api
from openpype.pipeline import (
PublishValidationError,
OptionalPyblishPluginMixin
)
from openpype.pipeline.publish import (
ValidateContentsOrder,
RepairAction,
)
from openpype.hosts.houdini.api.action import SelectInvalidAction
from openpype.pipeline.create import get_subset_name
import hou
class FixSubsetNameAction(RepairAction):
label = "Fix Subset Name"
class ValidateSubsetName(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Validate Subset name.
"""
families = ["staticMesh"]
hosts = ["houdini"]
label = "Validate Subset Name"
order = ValidateContentsOrder + 0.1
actions = [FixSubsetNameAction, SelectInvalidAction]
optional = True
def process(self, instance):
if not self.is_active(instance.data):
return
invalid = self.get_invalid(instance)
if invalid:
nodes = [n.path() for n in invalid]
raise PublishValidationError(
"See log for details. "
"Invalid nodes: {0}".format(nodes)
)
@classmethod
def get_invalid(cls, instance):
invalid = []
rop_node = hou.node(instance.data["instance_node"])
# Check subset name
subset_name = get_subset_name(
family=instance.data["family"],
variant=instance.data["variant"],
task_name=instance.data["task"],
asset_doc=instance.data["assetEntity"],
dynamic_data={"asset": instance.data["asset"]}
)
if instance.data.get("subset") != subset_name:
invalid.append(rop_node)
cls.log.error(
"Invalid subset name on rop node '%s' should be '%s'.",
rop_node.path(), subset_name
)
return invalid
@classmethod
def repair(cls, instance):
rop_node = hou.node(instance.data["instance_node"])
# Check subset name
subset_name = get_subset_name(
family=instance.data["family"],
variant=instance.data["variant"],
task_name=instance.data["task"],
asset_doc=instance.data["assetEntity"],
dynamic_data={"asset": instance.data["asset"]}
)
instance.data["subset"] = subset_name
rop_node.parm("subset").set(subset_name)
cls.log.debug(
"Subset name on rop node '%s' has been set to '%s'.",
rop_node.path(), subset_name
)

View file

@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
"""Validator for correct naming of Static Meshes."""
import pyblish.api
from openpype.pipeline import (
PublishValidationError,
OptionalPyblishPluginMixin
)
from openpype.pipeline.publish import ValidateContentsOrder
from openpype.hosts.houdini.api.action import SelectInvalidAction
from openpype.hosts.houdini.api.lib import get_output_children
import hou
class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Validate name of Unreal Static Mesh.
This validator checks if output node name has a collision prefix:
- UBX
- UCP
- USP
- UCX
This validator also checks if subset name is correct
- {static mesh prefix}_{Asset-Name}{Variant}.
"""
families = ["staticMesh"]
hosts = ["houdini"]
label = "Unreal Static Mesh Name (FBX)"
order = ValidateContentsOrder + 0.1
actions = [SelectInvalidAction]
optional = True
collision_prefixes = []
static_mesh_prefix = ""
@classmethod
def apply_settings(cls, project_settings, system_settings):
settings = (
project_settings["houdini"]["create"]["CreateStaticMesh"]
)
cls.collision_prefixes = settings["collision_prefixes"]
cls.static_mesh_prefix = settings["static_mesh_prefix"]
def process(self, instance):
if not self.is_active(instance.data):
return
invalid = self.get_invalid(instance)
if invalid:
nodes = [n.path() for n in invalid]
raise PublishValidationError(
"See log for details. "
"Invalid nodes: {0}".format(nodes)
)
@classmethod
def get_invalid(cls, instance):
invalid = []
rop_node = hou.node(instance.data["instance_node"])
output_node = instance.data.get("output_node")
if output_node is None:
cls.log.debug(
"No Output Node, skipping check.."
)
return
if rop_node.evalParm("buildfrompath"):
# This validator doesn't support naming check if
# building hierarchy from path' is used
cls.log.info(
"Using 'Build Hierarchy from Path Attribute', skipping check.."
)
return
# Check nodes names
all_outputs = get_output_children(output_node, include_sops=False)
for output in all_outputs:
for prefix in cls.collision_prefixes:
if output.name().startswith(prefix):
invalid.append(output)
cls.log.error(
"Invalid node name: Node '%s' "
"includes a collision prefix '%s'",
output.path(), prefix
)
break
return invalid

View file

@ -18,7 +18,6 @@ from openpype.hosts.max.api import lib
from openpype.hosts.max.api.plugin import MS_CUSTOM_ATTRIB
from openpype.hosts.max import MAX_HOST_DIR
from pymxs import runtime as rt # noqa
log = logging.getLogger("openpype.hosts.max")

View file

@ -14,7 +14,7 @@ class ModelAbcLoader(load.LoaderPlugin):
"""Loading model with the Alembic loader."""
families = ["model"]
label = "Load Model(Alembic)"
label = "Load Model with Alembic"
representations = ["abc"]
order = -10
icon = "code-fork"

View file

@ -55,7 +55,7 @@ class AbcLoader(load.LoaderPlugin):
selections = rt.GetCurrentSelection()
for abc in selections:
for cam_shape in abc.Children:
cam_shape.playbackType = 2
cam_shape.playbackType = 0
namespace = unique_namespace(
name + "_",

View file

@ -0,0 +1,108 @@
import os
from openpype.pipeline import load, get_representation_path
from openpype.pipeline.load import LoadError
from openpype.hosts.max.api.pipeline import (
containerise,
get_previous_loaded_object,
update_custom_attribute_data
)
from openpype.hosts.max.api.lib import (
unique_namespace,
get_namespace,
object_transform_set,
get_plugins
)
from openpype.hosts.max.api import lib
from pymxs import runtime as rt
class OxAbcLoader(load.LoaderPlugin):
"""Ornatrix Alembic loader."""
families = ["camera", "animation", "pointcache"]
label = "Load Alembic with Ornatrix"
representations = ["abc"]
order = -10
icon = "code-fork"
color = "orange"
postfix = "param"
def load(self, context, name=None, namespace=None, data=None):
plugin_list = get_plugins()
if "ephere.plugins.autodesk.max.ornatrix.dlo" not in plugin_list:
raise LoadError("Ornatrix plugin not "
"found/installed in Max yet..")
file_path = os.path.normpath(self.filepath_from_context(context))
rt.AlembicImport.ImportToRoot = True
rt.AlembicImport.CustomAttributes = True
rt.importFile(
file_path, rt.name("noPrompt"),
using=rt.Ornatrix_Alembic_Importer)
scene_object = []
for obj in rt.rootNode.Children:
obj_type = rt.ClassOf(obj)
if str(obj_type).startswith("Ox_"):
scene_object.append(obj)
namespace = unique_namespace(
name + "_",
suffix="_",
)
abc_container = []
for abc in scene_object:
abc.name = f"{namespace}:{abc.name}"
abc_container.append(abc)
return containerise(
name, abc_container, context,
namespace, loader=self.__class__.__name__
)
def update(self, container, representation):
path = get_representation_path(representation)
node_name = container["instance_node"]
namespace, name = get_namespace(node_name)
node = rt.getNodeByName(node_name)
node_list = get_previous_loaded_object(node)
rt.Select(node_list)
selections = rt.getCurrentSelection()
transform_data = object_transform_set(selections)
for prev_obj in selections:
if rt.isValidNode(prev_obj):
rt.Delete(prev_obj)
rt.AlembicImport.ImportToRoot = False
rt.AlembicImport.CustomAttributes = True
rt.importFile(
path, rt.name("noPrompt"),
using=rt.Ornatrix_Alembic_Importer)
scene_object = []
for obj in rt.rootNode.Children:
obj_type = rt.ClassOf(obj)
if str(obj_type).startswith("Ox_"):
scene_object.append(obj)
ox_abc_objects = []
for abc in scene_object:
abc.Parent = container
abc.name = f"{namespace}:{abc.name}"
ox_abc_objects.append(abc)
ox_transform = f"{abc.name}.transform"
if ox_transform in transform_data.keys():
abc.pos = transform_data[ox_transform] or 0
abc.scale = transform_data[f"{abc.name}.scale"] or 0
update_custom_attribute_data(node, ox_abc_objects)
lib.imprint(
container["instance_node"],
{"representation": str(representation["_id"])},
)
def switch(self, container, representation):
self.update(container, representation)
def remove(self, container):
node = rt.GetNodeByName(container["instance_node"])
rt.Delete(node)

View file

@ -659,17 +659,6 @@ def on_task_changed():
lib.set_context_settings()
lib.update_content_on_context_change()
msg = " project: {}\n asset: {}\n task:{}".format(
get_current_project_name(),
get_current_asset_name(),
get_current_task_name()
)
lib.show_message(
"Context was changed",
("Context was changed to:\n{}".format(msg)),
)
def before_workfile_open():
if handle_workfile_locks():

View file

@ -129,18 +129,49 @@ class MayaCreatorBase(object):
shared_data["maya_cached_legacy_subsets"] = cache_legacy
return shared_data
def get_publish_families(self):
"""Return families for the instances of this creator.
Allow a Creator to define multiple families so that a creator can
e.g. specify `usd` and `usdMaya` and another USD creator can also
specify `usd` but apply different extractors like `usdMultiverse`.
There is no need to override this method if you only have the
primary family defined by the `family` property as that will always
be set.
Returns:
list: families for instances of this creator
"""
return []
def imprint_instance_node(self, node, data):
# We never store the instance_node as value on the node since
# it's the node name itself
data.pop("instance_node", None)
# Don't store `families` since it's up to the creator itself
# to define the initial publish families - not a stored attribute of
# `families`
data.pop("families", None)
# We store creator attributes at the root level and assume they
# will not clash in names with `subset`, `task`, etc. and other
# default names. This is just so these attributes in many cases
# are still editable in the maya UI by artists.
# pop to move to end of dict to sort attributes last on the node
# note: pop to move to end of dict to sort attributes last on the node
creator_attributes = data.pop("creator_attributes", {})
# We only flatten value types which `imprint` function supports
json_creator_attributes = {}
for key, value in dict(creator_attributes).items():
if isinstance(value, (list, tuple, dict)):
creator_attributes.pop(key)
json_creator_attributes[key] = value
# Flatten remaining creator attributes to the node itself
data.update(creator_attributes)
# We know the "publish_attributes" will be complex data of
@ -150,6 +181,10 @@ class MayaCreatorBase(object):
data.pop("publish_attributes", {})
)
# Persist the non-flattened creator attributes (special value types,
# like multiselection EnumDef)
data["creator_attributes"] = json.dumps(json_creator_attributes)
# Since we flattened the data structure for creator attributes we want
# to correctly detect which flattened attributes should end back in the
# creator attributes when reading the data from the node, so we store
@ -170,15 +205,22 @@ class MayaCreatorBase(object):
# being read as 'data'
node_data.pop("cbId", None)
# Make sure we convert any creator attributes from the json string
creator_attributes = node_data.get("creator_attributes")
if creator_attributes:
node_data["creator_attributes"] = json.loads(creator_attributes)
else:
node_data["creator_attributes"] = {}
# Move the relevant attributes into "creator_attributes" that
# we flattened originally
node_data["creator_attributes"] = {}
creator_attribute_keys = node_data.pop("__creator_attributes_keys",
"").split(",")
for key in creator_attribute_keys:
if key in node_data:
node_data["creator_attributes"][key] = node_data.pop(key)
# Make sure we convert any publish attributes from the json string
publish_attributes = node_data.get("publish_attributes")
if publish_attributes:
node_data["publish_attributes"] = json.loads(publish_attributes)
@ -186,6 +228,11 @@ class MayaCreatorBase(object):
# Explicitly re-parse the node name
node_data["instance_node"] = node
# If the creator plug-in specifies
families = self.get_publish_families()
if families:
node_data["families"] = families
return node_data
def _default_collect_instances(self):
@ -230,6 +277,14 @@ class MayaCreator(NewCreator, MayaCreatorBase):
if pre_create_data.get("use_selection"):
members = cmds.ls(selection=True)
# Allow a Creator to define multiple families
publish_families = self.get_publish_families()
if publish_families:
families = instance_data.setdefault("families", [])
for family in self.get_publish_families():
if family not in families:
families.append(family)
with lib.undo_chunk():
instance_node = cmds.sets(members, name=subset_name)
instance_data["instance_node"] = instance_node

View file

@ -0,0 +1,102 @@
from openpype.hosts.maya.api import plugin, lib
from openpype.lib import (
BoolDef,
EnumDef,
TextDef
)
from maya import cmds
class CreateMayaUsd(plugin.MayaCreator):
"""Create Maya USD Export"""
identifier = "io.openpype.creators.maya.mayausd"
label = "Maya USD"
family = "usd"
icon = "cubes"
description = "Create Maya USD Export"
cache = {}
def get_publish_families(self):
return ["usd", "mayaUsd"]
def get_instance_attr_defs(self):
if "jobContextItems" not in self.cache:
# Query once instead of per instance
job_context_items = {}
try:
cmds.loadPlugin("mayaUsdPlugin", quiet=True)
job_context_items = {
cmds.mayaUSDListJobContexts(jobContext=name): name
for name in cmds.mayaUSDListJobContexts(export=True) or []
}
except RuntimeError:
# Likely `mayaUsdPlugin` plug-in not available
self.log.warning("Unable to retrieve available job "
"contexts for `mayaUsdPlugin` exports")
if not job_context_items:
# enumdef multiselection may not be empty
job_context_items = ["<placeholder; do not use>"]
self.cache["jobContextItems"] = job_context_items
defs = lib.collect_animation_defs()
defs.extend([
EnumDef("defaultUSDFormat",
label="File format",
items={
"usdc": "Binary",
"usda": "ASCII"
},
default="usdc"),
BoolDef("stripNamespaces",
label="Strip Namespaces",
tooltip=(
"Remove namespaces during export. By default, "
"namespaces are exported to the USD file in the "
"following format: nameSpaceExample_pPlatonic1"
),
default=True),
BoolDef("mergeTransformAndShape",
label="Merge Transform and Shape",
tooltip=(
"Combine Maya transform and shape into a single USD"
"prim that has transform and geometry, for all"
" \"geometric primitives\" (gprims).\n"
"This results in smaller and faster scenes. Gprims "
"will be \"unpacked\" back into transform and shape "
"nodes when imported into Maya from USD."
),
default=True),
BoolDef("includeUserDefinedAttributes",
label="Include User Defined Attributes",
tooltip=(
"Whether to include all custom maya attributes found "
"on nodes as metadata (userProperties) in USD."
),
default=False),
TextDef("attr",
label="Custom Attributes",
default="",
placeholder="attr1, attr2"),
TextDef("attrPrefix",
label="Custom Attributes Prefix",
default="",
placeholder="prefix1, prefix2"),
EnumDef("jobContext",
label="Job Context",
items=self.cache["jobContextItems"],
tooltip=(
"Specifies an additional export context to handle.\n"
"These usually contain extra schemas, primitives,\n"
"and materials that are to be exported for a "
"specific\ntask, a target renderer for example."
),
multiselection=True),
])
return defs

View file

@ -14,6 +14,10 @@ class CreateMultiverseUsd(plugin.MayaCreator):
label = "Multiverse USD Asset"
family = "usd"
icon = "cubes"
description = "Create Multiverse USD Asset"
def get_publish_families(self):
return ["usd", "mvUsd"]
def get_instance_attr_defs(self):

View file

@ -17,6 +17,7 @@ from openpype.hosts.maya.api.lib import (
)
from openpype.hosts.maya.api.pipeline import containerise
def is_sequence(files):
sequence = False
collections, remainder = clique.assemble(files, minimum_items=1)
@ -29,11 +30,12 @@ def get_current_session_fps():
session_fps = float(legacy_io.Session.get('AVALON_FPS', 25))
return convert_to_maya_fps(session_fps)
class ArnoldStandinLoader(load.LoaderPlugin):
"""Load as Arnold standin"""
families = ["ass", "animation", "model", "proxyAbc", "pointcache"]
representations = ["ass", "abc"]
families = ["ass", "animation", "model", "proxyAbc", "pointcache", "usd"]
representations = ["ass", "abc", "usda", "usdc", "usd"]
label = "Load as Arnold standin"
order = -5

View file

@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
import maya.cmds as cmds
from openpype.pipeline import (
load,
get_representation_path,
)
from openpype.pipeline.load import get_representation_path_from_context
from openpype.hosts.maya.api.lib import (
namespaced,
unique_namespace
)
from openpype.hosts.maya.api.pipeline import containerise
class MayaUsdLoader(load.LoaderPlugin):
"""Read USD data in a Maya USD Proxy"""
families = ["model", "usd", "pointcache", "animation"]
representations = ["usd", "usda", "usdc", "usdz", "abc"]
label = "Load USD to Maya Proxy"
order = -1
icon = "code-fork"
color = "orange"
def load(self, context, name=None, namespace=None, options=None):
asset = context['asset']['name']
namespace = namespace or unique_namespace(
asset + "_",
prefix="_" if asset[0].isdigit() else "",
suffix="_",
)
# Make sure we can load the plugin
cmds.loadPlugin("mayaUsdPlugin", quiet=True)
path = get_representation_path_from_context(context)
# Create the shape
cmds.namespace(addNamespace=namespace)
with namespaced(namespace, new=False):
transform = cmds.createNode("transform",
name=name,
skipSelect=True)
proxy = cmds.createNode('mayaUsdProxyShape',
name="{}Shape".format(name),
parent=transform,
skipSelect=True)
cmds.connectAttr("time1.outTime", "{}.time".format(proxy))
cmds.setAttr("{}.filePath".format(proxy), path, type="string")
# By default, we force the proxy to not use a shared stage because
# when doing so Maya will quite easily allow to save into the
# loaded usd file. Since we are loading published files we want to
# avoid altering them. Unshared stages also save their edits into
# the workfile as an artist might expect it to do.
cmds.setAttr("{}.shareStage".format(proxy), False)
# cmds.setAttr("{}.shareStage".format(proxy), lock=True)
nodes = [transform, proxy]
self[:] = nodes
return containerise(
name=name,
namespace=namespace,
nodes=nodes,
context=context,
loader=self.__class__.__name__)
def update(self, container, representation):
# type: (dict, dict) -> None
"""Update container with specified representation."""
node = container['objectName']
assert cmds.objExists(node), "Missing container"
members = cmds.sets(node, query=True) or []
shapes = cmds.ls(members, type="mayaUsdProxyShape")
path = get_representation_path(representation)
for shape in shapes:
cmds.setAttr("{}.filePath".format(shape), path, type="string")
cmds.setAttr("{}.representation".format(node),
str(representation["_id"]),
type="string")
def switch(self, container, representation):
self.update(container, representation)
def remove(self, container):
# type: (dict) -> None
"""Remove loaded container."""
# Delete container and its contents
if cmds.objExists(container['objectName']):
members = cmds.sets(container['objectName'], query=True) or []
cmds.delete([container['objectName']] + members)
# Remove the namespace, if empty
namespace = container['namespace']
if cmds.namespace(exists=namespace):
members = cmds.namespaceInfo(namespace, listNamespace=True)
if not members:
cmds.namespace(removeNamespace=namespace)
else:
self.log.warning("Namespace not deleted because it "
"still has members: %s", namespace)

View file

@ -58,17 +58,3 @@ class CollectAnimationOutputGeometry(pyblish.api.InstancePlugin):
if instance.data.get("farm"):
instance.data["families"].append("publish.farm")
# Collect user defined attributes.
if not instance.data.get("includeUserDefinedAttributes", False):
return
user_defined_attributes = set()
for node in hierarchy:
attrs = cmds.listAttr(node, userDefined=True) or list()
shapes = cmds.listRelatives(node, shapes=True) or list()
for shape in shapes:
attrs.extend(cmds.listAttr(shape, userDefined=True) or list())
user_defined_attributes.update(attrs)
instance.data["userDefinedAttributes"] = list(user_defined_attributes)

View file

@ -45,18 +45,3 @@ class CollectPointcache(pyblish.api.InstancePlugin):
if proxy_set:
instance.remove(proxy_set)
instance.data["setMembers"].remove(proxy_set)
# Collect user defined attributes.
if not instance.data.get("includeUserDefinedAttributes", False):
return
user_defined_attributes = set()
for node in instance:
attrs = cmds.listAttr(node, userDefined=True) or list()
shapes = cmds.listRelatives(node, shapes=True) or list()
for shape in shapes:
attrs.extend(cmds.listAttr(shape, userDefined=True) or list())
user_defined_attributes.update(attrs)
instance.data["userDefinedAttributes"] = list(user_defined_attributes)

View file

@ -157,10 +157,10 @@ class CollectMayaRender(pyblish.api.InstancePlugin):
# append full path
aov_dict = {}
default_render_folder = context.data.get("project_settings")\
.get("maya")\
.get("RenderSettings")\
.get("default_render_image_folder") or ""
image_directory = os.path.join(
cmds.workspace(query=True, rootDirectory=True),
cmds.workspace(fileRuleEntry="images")
)
# replace relative paths with absolute. Render products are
# returned as list of dictionaries.
publish_meta_path = None
@ -168,8 +168,7 @@ class CollectMayaRender(pyblish.api.InstancePlugin):
full_paths = []
aov_first_key = list(aov.keys())[0]
for file in aov[aov_first_key]:
full_path = os.path.join(workspace, default_render_folder,
file)
full_path = os.path.join(image_directory, file)
full_path = full_path.replace("\\", "/")
full_paths.append(full_path)
publish_meta_path = os.path.dirname(full_path)

View file

@ -0,0 +1,39 @@
from maya import cmds
import pyblish.api
class CollectUserDefinedAttributes(pyblish.api.InstancePlugin):
"""Collect user defined attributes for nodes in instance."""
order = pyblish.api.CollectorOrder + 0.45
families = ["pointcache", "animation", "usd"]
label = "Collect User Defined Attributes"
hosts = ["maya"]
def process(self, instance):
# Collect user defined attributes.
if not instance.data.get("includeUserDefinedAttributes", False):
return
if "out_hierarchy" in instance.data:
# animation family
nodes = instance.data["out_hierarchy"]
else:
nodes = instance[:]
if not nodes:
return
shapes = cmds.listRelatives(nodes, shapes=True, fullPath=True) or []
nodes = set(nodes).union(shapes)
attrs = cmds.listAttr(list(nodes), userDefined=True) or []
user_defined_attributes = list(sorted(set(attrs)))
instance.data["userDefinedAttributes"] = user_defined_attributes
self.log.debug(
"Collected user defined attributes: {}".format(
", ".join(user_defined_attributes)
)
)

View file

@ -0,0 +1,293 @@
import os
import six
import json
import contextlib
from maya import cmds
import pyblish.api
from openpype.pipeline import publish
from openpype.hosts.maya.api.lib import maintained_selection
@contextlib.contextmanager
def usd_export_attributes(nodes, attrs=None, attr_prefixes=None, mapping=None):
"""Define attributes for the given nodes that should be exported.
MayaUSDExport will export custom attributes if the Maya node has a
string attribute `USD_UserExportedAttributesJson` that provides an
export mapping for the maya attributes. This context manager will try
to autogenerate such an attribute during the export to include attributes
for the export.
Arguments:
nodes (List[str]): Nodes to process.
attrs (Optional[List[str]]): Full name of attributes to include.
attr_prefixes (Optional[List[str]]): Prefixes of attributes to include.
mapping (Optional[Dict[Dict]]): A mapping per attribute name for the
conversion to a USD attribute, including renaming, defining type,
converting attribute precision, etc. This match the usual
`USD_UserExportedAttributesJson` json mapping of `mayaUSDExport`.
When no mapping provided for an attribute it will use `{}` as
value.
Examples:
>>> with usd_export_attributes(
>>> ["pCube1"], attrs="myDoubleAttributeAsFloat", mapping={
>>> "myDoubleAttributeAsFloat": {
>>> "usdAttrName": "my:namespace:attrib",
>>> "translateMayaDoubleToUsdSinglePrecision": True,
>>> }
>>> })
"""
# todo: this might be better done with a custom export chaser
# see `chaser` argument for `mayaUSDExport`
import maya.api.OpenMaya as om
if not attrs and not attr_prefixes:
# context manager does nothing
yield
return
if attrs is None:
attrs = []
if attr_prefixes is None:
attr_prefixes = []
if mapping is None:
mapping = {}
usd_json_attr = "USD_UserExportedAttributesJson"
strings = attrs + ["{}*".format(prefix) for prefix in attr_prefixes]
context_state = {}
for node in set(nodes):
node_attrs = cmds.listAttr(node, st=strings)
if not node_attrs:
# Nothing to do for this node
continue
node_attr_data = {}
for node_attr in set(node_attrs):
node_attr_data[node_attr] = mapping.get(node_attr, {})
if cmds.attributeQuery(usd_json_attr, node=node, exists=True):
existing_node_attr_value = cmds.getAttr(
"{}.{}".format(node, usd_json_attr)
)
if existing_node_attr_value and existing_node_attr_value != "{}":
# Any existing attribute mappings in an existing
# `USD_UserExportedAttributesJson` attribute always take
# precedence over what this function tries to imprint
existing_node_attr_data = json.loads(existing_node_attr_value)
node_attr_data.update(existing_node_attr_data)
context_state[node] = json.dumps(node_attr_data)
sel = om.MSelectionList()
dg_mod = om.MDGModifier()
fn_string = om.MFnStringData()
fn_typed = om.MFnTypedAttribute()
try:
for node, value in context_state.items():
data = fn_string.create(value)
sel.clear()
if cmds.attributeQuery(usd_json_attr, node=node, exists=True):
# Set the attribute value
sel.add("{}.{}".format(node, usd_json_attr))
plug = sel.getPlug(0)
dg_mod.newPlugValue(plug, data)
else:
# Create attribute with the value as default value
sel.add(node)
node_obj = sel.getDependNode(0)
attr_obj = fn_typed.create(usd_json_attr,
usd_json_attr,
om.MFnData.kString,
data)
dg_mod.addAttribute(node_obj, attr_obj)
dg_mod.doIt()
yield
finally:
dg_mod.undoIt()
class ExtractMayaUsd(publish.Extractor):
"""Extractor for Maya USD Asset data.
Upon publish a .usd (or .usdz) asset file will typically be written.
"""
label = "Extract Maya USD Asset"
hosts = ["maya"]
families = ["mayaUsd"]
@property
def options(self):
"""Overridable options for Maya USD Export
Given in the following format
- {NAME: EXPECTED TYPE}
If the overridden option's type does not match,
the option is not included and a warning is logged.
"""
# TODO: Support more `mayaUSDExport` parameters
return {
"defaultUSDFormat": str,
"stripNamespaces": bool,
"mergeTransformAndShape": bool,
"exportDisplayColor": bool,
"exportColorSets": bool,
"exportInstances": bool,
"exportUVs": bool,
"exportVisibility": bool,
"exportComponentTags": bool,
"exportRefsAsInstanceable": bool,
"eulerFilter": bool,
"renderableOnly": bool,
"jobContext": (list, None) # optional list
# "worldspace": bool,
}
@property
def default_options(self):
"""The default options for Maya USD Export."""
# TODO: Support more `mayaUSDExport` parameters
return {
"defaultUSDFormat": "usdc",
"stripNamespaces": False,
"mergeTransformAndShape": False,
"exportDisplayColor": False,
"exportColorSets": True,
"exportInstances": True,
"exportUVs": True,
"exportVisibility": True,
"exportComponentTags": True,
"exportRefsAsInstanceable": False,
"eulerFilter": True,
"renderableOnly": False,
"jobContext": None
# "worldspace": False
}
def parse_overrides(self, instance, options):
"""Inspect data of instance to determine overridden options"""
for key in instance.data:
if key not in self.options:
continue
# Ensure the data is of correct type
value = instance.data[key]
if isinstance(value, six.text_type):
value = str(value)
if not isinstance(value, self.options[key]):
self.log.warning(
"Overridden attribute {key} was of "
"the wrong type: {invalid_type} "
"- should have been {valid_type}".format(
key=key,
invalid_type=type(value).__name__,
valid_type=self.options[key].__name__))
continue
options[key] = value
return options
def filter_members(self, members):
# Can be overridden by inherited classes
return members
def process(self, instance):
# Load plugin first
cmds.loadPlugin("mayaUsdPlugin", quiet=True)
# Define output file path
staging_dir = self.staging_dir(instance)
file_name = "{0}.usd".format(instance.name)
file_path = os.path.join(staging_dir, file_name)
file_path = file_path.replace('\\', '/')
# Parse export options
options = self.default_options
options = self.parse_overrides(instance, options)
self.log.debug("Export options: {0}".format(options))
# Perform extraction
self.log.debug("Performing extraction ...")
members = instance.data("setMembers")
self.log.debug('Collected objects: {}'.format(members))
members = self.filter_members(members)
if not members:
self.log.error('No members!')
return
start = instance.data["frameStartHandle"]
end = instance.data["frameEndHandle"]
def parse_attr_str(attr_str):
result = list()
for attr in attr_str.split(","):
attr = attr.strip()
if not attr:
continue
result.append(attr)
return result
attrs = parse_attr_str(instance.data.get("attr", ""))
attrs += instance.data.get("userDefinedAttributes", [])
attrs += ["cbId"]
attr_prefixes = parse_attr_str(instance.data.get("attrPrefix", ""))
self.log.debug('Exporting USD: {} / {}'.format(file_path, members))
with maintained_selection():
with usd_export_attributes(instance[:],
attrs=attrs,
attr_prefixes=attr_prefixes):
cmds.mayaUSDExport(file=file_path,
frameRange=(start, end),
frameStride=instance.data.get("step", 1.0),
exportRoots=members,
**options)
representation = {
'name': "usd",
'ext': "usd",
'files': file_name,
'stagingDir': staging_dir
}
instance.data.setdefault("representations", []).append(representation)
self.log.debug(
"Extracted instance {} to {}".format(instance.name, file_path)
)
class ExtractMayaUsdAnim(ExtractMayaUsd):
"""Extractor for Maya USD Animation Sparse Cache data.
This will extract the sparse cache data from the scene and generate a
USD file with all the animation data.
Upon publish a .usd sparse cache will be written.
"""
label = "Extract Maya USD Animation Sparse Cache"
families = ["animation", "mayaUsd"]
match = pyblish.api.Subset
def filter_members(self, members):
out_set = next((i for i in members if i.endswith("out_SET")), None)
if out_set is None:
self.log.warning("Expecting out_SET")
return None
members = cmds.ls(cmds.sets(out_set, query=True), long=True)
return members

View file

@ -28,7 +28,7 @@ class ExtractMultiverseUsd(publish.Extractor):
label = "Extract Multiverse USD Asset"
hosts = ["maya"]
families = ["usd"]
families = ["mvUsd"]
scene_type = "usd"
file_formats = ["usd", "usda", "usdz"]

View file

@ -107,7 +107,8 @@ class ExtractAlembic(publish.Extractor):
}
instance.data["representations"].append(representation)
instance.context.data["cleanupFullPaths"].append(path)
if not instance.data.get("stagingDir_persistent", False):
instance.context.data["cleanupFullPaths"].append(path)
self.log.debug("Extracted {} to {}".format(instance, dirname))

View file

@ -80,7 +80,8 @@ class ExtractProxyAlembic(publish.Extractor):
}
instance.data["representations"].append(representation)
instance.context.data["cleanupFullPaths"].append(path)
if not instance.data.get("stagingDir_persistent", False):
instance.context.data["cleanupFullPaths"].append(path)
self.log.debug("Extracted {} to {}".format(instance, dirname))
# remove the bounding box

View file

@ -92,7 +92,6 @@ class ExtractThumbnail(publish.Extractor):
"Create temp directory {} for thumbnail".format(dst_staging)
)
# Store new staging to cleanup paths
instance.context.data["cleanupFullPaths"].append(dst_staging)
filename = "{0}".format(instance.name)
path = os.path.join(dst_staging, filename)

View file

@ -3,9 +3,10 @@ from maya import cmds
import pyblish.api
import openpype.hosts.maya.api.action
from openpype.pipeline.publish import (
RepairAction,
ValidateMeshOrder,
OptionalPyblishPluginMixin
OptionalPyblishPluginMixin,
PublishValidationError,
RepairAction
)
@ -22,8 +23,9 @@ class ValidateColorSets(pyblish.api.Validator,
hosts = ['maya']
families = ['model']
label = 'Mesh ColorSets'
actions = [openpype.hosts.maya.api.action.SelectInvalidAction,
RepairAction]
actions = [
openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction
]
optional = True
@staticmethod
@ -48,8 +50,9 @@ class ValidateColorSets(pyblish.api.Validator,
invalid = self.get_invalid(instance)
if invalid:
raise ValueError("Meshes found with "
"Color Sets: {0}".format(invalid))
raise PublishValidationError(
message="Meshes found with Color Sets: {0}".format(invalid)
)
@classmethod
def repair(cls, instance):

View file

@ -1,4 +1,7 @@
import os
import pyblish.api
from maya import cmds
from openpype.pipeline.publish import (
@ -22,8 +25,12 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin):
def process(self, instance):
required_images_rule = self.get_default_render_image_folder(instance)
current_images_rule = cmds.workspace(fileRuleEntry="images")
required_images_rule = os.path.normpath(
self.get_default_render_image_folder(instance)
)
current_images_rule = os.path.normpath(
cmds.workspace(fileRuleEntry="images")
)
if current_images_rule != required_images_rule:
raise PublishValidationError(
@ -42,8 +49,17 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin):
cmds.workspace(fileRule=("images", required_images_rule))
cmds.workspace(saveWorkspace=True)
@staticmethod
def get_default_render_image_folder(instance):
@classmethod
def get_default_render_image_folder(cls, instance):
staging_dir = instance.data.get("stagingDir")
if staging_dir:
cls.log.debug(
"Staging dir found: \"{}\". Ignoring setting from "
"`project_settings/maya/RenderSettings/"
"default_render_image_folder`.".format(staging_dir)
)
return staging_dir
return instance.context.data.get('project_settings')\
.get('maya') \
.get('RenderSettings') \

View file

@ -10,6 +10,7 @@ from openpype.client import get_last_version_by_subset_name
from openpype.hosts.maya import api
from . import lib
from .alembic import get_alembic_ids_cache
from .usd import is_usd_lib_supported, get_usd_ids_cache
log = logging.getLogger(__name__)
@ -74,6 +75,13 @@ def get_nodes_by_id(standin):
# Support alembic files directly
return get_alembic_ids_cache(path)
elif (
is_usd_lib_supported and
any(path.endswith(ext) for ext in [".usd", ".usda", ".usdc"])
):
# Support usd files directly
return get_usd_ids_cache(path)
json_path = None
for f in os.listdir(os.path.dirname(path)):
if f.endswith(".json"):

View file

@ -0,0 +1,38 @@
from collections import defaultdict
try:
from pxr import Usd
is_usd_lib_supported = True
except ImportError:
is_usd_lib_supported = False
def get_usd_ids_cache(path):
# type: (str) -> dict
"""Build a id to node mapping in a USD file.
Nodes without IDs are ignored.
Returns:
dict: Mapping of id to nodes in the USD file.
"""
if not is_usd_lib_supported:
raise RuntimeError("No pxr.Usd python library available.")
stage = Usd.Stage.Open(path)
ids = {}
for prim in stage.Traverse():
attr = prim.GetAttribute("userProperties:cbId")
if not attr.IsValid():
continue
value = attr.Get()
if not value:
continue
path = str(prim.GetPath())
ids[path] = value
cache = defaultdict(list)
for path, value in ids.items():
cache[value].append(path)
return dict(cache)

View file

@ -6,7 +6,10 @@ from PIL import Image
import pyblish.api
from openpype.pipeline.publish import KnownPublishError
from openpype.pipeline.publish import (
KnownPublishError,
get_publish_instance_families,
)
from openpype.hosts.tvpaint.api.lib import (
execute_george,
execute_george_through_file,
@ -140,8 +143,9 @@ class ExtractSequence(pyblish.api.Extractor):
)
# Fill tags and new families from project settings
instance_families = get_publish_instance_families(instance)
tags = []
if "review" in instance.data["families"]:
if "review" in instance_families:
tags.append("review")
# Sequence of one frame

View file

@ -494,10 +494,18 @@ class OpenPypeSettingsRegistry(JSONSettingRegistry):
"""
def __init__(self, name=None):
self.vendor = "pypeclub"
self.product = "openpype"
if AYON_SERVER_ENABLED:
vendor = "Ynput"
product = "AYON"
default_name = "AYON_settings"
else:
vendor = "pypeclub"
product = "openpype"
default_name = "openpype_settings"
self.vendor = vendor
self.product = product
if not name:
name = "openpype_settings"
name = default_name
path = appdirs.user_data_dir(self.product, self.vendor)
super(OpenPypeSettingsRegistry, self).__init__(name, path)

View file

@ -59,6 +59,14 @@ IGNORED_DEFAULT_FILENAMES = (
"example_addons",
"default_modules",
)
# Modules that won't be loaded in AYON mode from "./openpype/modules"
# - the same modules are ignored in "./server_addon/create_ayon_addons.py"
IGNORED_FILENAMES_IN_AYON = {
"ftrack",
"shotgrid",
"sync_server",
"slack",
}
# Inherit from `object` for Python 2 hosts
@ -392,9 +400,9 @@ def _load_ayon_addons(openpype_modules, modules_key, log):
folder_name = "{}_{}".format(addon_name, addon_version)
addon_dir = os.path.join(addons_dir, folder_name)
if not os.path.exists(addon_dir):
log.warning((
"Directory for addon {} {} does not exists. Path \"{}\""
).format(addon_name, addon_version, addon_dir))
log.debug((
"No localized client code found for addon {} {}."
).format(addon_name, addon_version))
continue
sys.path.insert(0, addon_dir)
@ -483,6 +491,10 @@ def _load_modules():
is_in_current_dir = dirpath == current_dir
is_in_host_dir = dirpath == hosts_dir
ignored_current_dir_filenames = set(IGNORED_DEFAULT_FILENAMES)
if AYON_SERVER_ENABLED:
ignored_current_dir_filenames |= IGNORED_FILENAMES_IN_AYON
for filename in os.listdir(dirpath):
# Ignore filenames
if filename in IGNORED_FILENAMES:
@ -490,7 +502,7 @@ def _load_modules():
if (
is_in_current_dir
and filename in IGNORED_DEFAULT_FILENAMES
and filename in ignored_current_dir_filenames
):
continue

View file

@ -290,7 +290,6 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
def process_submission(self):
instance = self._instance
context = instance.context
filepath = self.scene_path # publish if `use_publish` else workfile
@ -306,13 +305,11 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
self._patch_workfile()
# Gather needed data ------------------------------------------------
workspace = context.data["workspaceDir"]
default_render_file = instance.context.data.get('project_settings')\
.get('maya')\
.get('RenderSettings')\
.get('default_render_image_folder')
filename = os.path.basename(filepath)
dirname = os.path.join(workspace, default_render_file)
dirname = os.path.join(
cmds.workspace(query=True, rootDirectory=True),
cmds.workspace(fileRuleEntry="images")
)
# Fill in common data to payload ------------------------------------
# TODO: Replace these with collected data from CollectRender

View file

@ -345,6 +345,151 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin,
self.log.debug("Skipping local instance.")
return
data = instance.data.copy()
context = instance.context
self.context = context
self.anatomy = instance.context.data["anatomy"]
asset = data.get("asset") or context.data["asset"]
subset = data.get("subset")
start = instance.data.get("frameStart")
if start is None:
start = context.data["frameStart"]
end = instance.data.get("frameEnd")
if end is None:
end = context.data["frameEnd"]
handle_start = instance.data.get("handleStart")
if handle_start is None:
handle_start = context.data["handleStart"]
handle_end = instance.data.get("handleEnd")
if handle_end is None:
handle_end = context.data["handleEnd"]
fps = instance.data.get("fps")
if fps is None:
fps = context.data["fps"]
if data.get("extendFrames", False):
start, end = self._extend_frames(
asset,
subset,
start,
end,
data["overrideExistingFrame"])
try:
source = data["source"]
except KeyError:
source = context.data["currentFile"]
success, rootless_path = (
self.anatomy.find_root_template_from_path(source)
)
if success:
source = rootless_path
else:
# `rootless_path` is not set to `source` if none of roots match
self.log.warning((
"Could not find root path for remapping \"{}\"."
" This may cause issues."
).format(source))
family = "render"
if ("prerender" in instance.data["families"] or
"prerender.farm" in instance.data["families"]):
family = "prerender"
families = [family]
# pass review to families if marked as review
do_not_add_review = False
if data.get("review"):
families.append("review")
elif data.get("review") is False:
self.log.debug("Instance has review explicitly disabled.")
do_not_add_review = True
instance_skeleton_data = {
"family": family,
"subset": subset,
"families": families,
"asset": asset,
"frameStart": start,
"frameEnd": end,
"handleStart": handle_start,
"handleEnd": handle_end,
"frameStartHandle": start - handle_start,
"frameEndHandle": end + handle_end,
"comment": instance.data["comment"],
"fps": fps,
"source": source,
"extendFrames": data.get("extendFrames"),
"overrideExistingFrame": data.get("overrideExistingFrame"),
"pixelAspect": data.get("pixelAspect", 1),
"resolutionWidth": data.get("resolutionWidth", 1920),
"resolutionHeight": data.get("resolutionHeight", 1080),
"multipartExr": data.get("multipartExr", False),
"jobBatchName": data.get("jobBatchName", ""),
"useSequenceForReview": data.get("useSequenceForReview", True),
# map inputVersions `ObjectId` -> `str` so json supports it
"inputVersions": list(map(str, data.get("inputVersions", []))),
"colorspace": instance.data.get("colorspace"),
"stagingDir_persistent": instance.data.get(
"stagingDir_persistent", False
)
}
# skip locking version if we are creating v01
instance_version = instance.data.get("version") # take this if exists
if instance_version != 1:
instance_skeleton_data["version"] = instance_version
# transfer specific families from original instance to new render
for item in self.families_transfer:
if item in instance.data.get("families", []):
instance_skeleton_data["families"] += [item]
# transfer specific properties from original instance based on
# mapping dictionary `instance_transfer`
for key, values in self.instance_transfer.items():
if key in instance.data.get("families", []):
for v in values:
instance_skeleton_data[v] = instance.data.get(v)
# look into instance data if representations are not having any
# which are having tag `publish_on_farm` and include them
for repre in instance.data.get("representations", []):
staging_dir = repre.get("stagingDir")
if staging_dir:
success, rootless_staging_dir = (
self.anatomy.find_root_template_from_path(
staging_dir
)
)
if success:
repre["stagingDir"] = rootless_staging_dir
else:
self.log.warning((
"Could not find root path for remapping \"{}\"."
" This may cause issues on farm."
).format(staging_dir))
repre["stagingDir"] = staging_dir
if "publish_on_farm" in repre.get("tags"):
# create representations attribute of not there
if "representations" not in instance_skeleton_data.keys():
instance_skeleton_data["representations"] = []
instance_skeleton_data["representations"].append(repre)
instances = None
assert data.get("expectedFiles"), ("Submission from old Pype version"
" - missing expectedFiles")
anatomy = instance.context.data["anatomy"]
instance_skeleton_data = create_skeleton_instance(

View file

@ -385,6 +385,12 @@ def inject_openpype_environment(deadlinePlugin):
for key, value in contents.items():
deadlinePlugin.SetProcessEnvironmentVariable(key, value)
if "PATH" in contents:
# Set os.environ[PATH] so studio settings' path entries
# can be used to define search path for executables.
print(f">>> Setting 'PATH' Environment to: {contents['PATH']}")
os.environ["PATH"] = contents["PATH"]
script_url = job.GetJobPluginInfoKeyValue("ScriptFilename")
if script_url:
script_url = script_url.format(**contents).replace("\\", "/")
@ -509,6 +515,12 @@ def inject_ayon_environment(deadlinePlugin):
for key, value in contents.items():
deadlinePlugin.SetProcessEnvironmentVariable(key, value)
if "PATH" in contents:
# Set os.environ[PATH] so studio settings' path entries
# can be used to define search path for executables.
print(f">>> Setting 'PATH' Environment to: {contents['PATH']}")
os.environ["PATH"] = contents["PATH"]
script_url = job.GetJobPluginInfoKeyValue("ScriptFilename")
if script_url:
script_url = script_url.format(**contents).replace("\\", "/")

View file

@ -1,3 +1,6 @@
import os
from openpype import PLUGINS_DIR, AYON_SERVER_ENABLED
from openpype.modules import (
OpenPypeModule,
ITrayAction,
@ -13,36 +16,66 @@ class LauncherAction(OpenPypeModule, ITrayAction):
self.enabled = True
# Tray attributes
self.window = None
self._window = None
def tray_init(self):
self.create_window()
self._create_window()
self.add_doubleclick_callback(self.show_launcher)
self.add_doubleclick_callback(self._show_launcher)
def tray_start(self):
return
def connect_with_modules(self, enabled_modules):
# Register actions
if self.tray_initialized:
from openpype.tools.launcher import actions
actions.register_config_actions()
actions_paths = self.manager.collect_plugin_paths()["actions"]
actions.register_actions_from_paths(actions_paths)
actions.register_environment_actions()
def create_window(self):
if self.window:
if not self.tray_initialized:
return
from openpype.tools.launcher import LauncherWindow
self.window = LauncherWindow()
from openpype.pipeline.actions import register_launcher_action_path
actions_dir = os.path.join(PLUGINS_DIR, "actions")
if os.path.exists(actions_dir):
register_launcher_action_path(actions_dir)
actions_paths = self.manager.collect_plugin_paths()["actions"]
for path in actions_paths:
if path and os.path.exists(path):
register_launcher_action_path(actions_dir)
paths_str = os.environ.get("AVALON_ACTIONS") or ""
if paths_str:
self.log.warning(
"WARNING: 'AVALON_ACTIONS' is deprecated. Support of this"
" environment variable will be removed in future versions."
" Please consider using 'OpenPypeModule' to define custom"
" action paths. Planned version to drop the support"
" is 3.17.2 or 3.18.0 ."
)
for path in paths_str.split(os.pathsep):
if path and os.path.exists(path):
register_launcher_action_path(path)
def on_action_trigger(self):
self.show_launcher()
"""Implementation for ITrayAction interface.
def show_launcher(self):
if self.window:
self.window.show()
self.window.raise_()
self.window.activateWindow()
Show launcher tool on action trigger.
"""
self._show_launcher()
def _create_window(self):
if self._window:
return
if AYON_SERVER_ENABLED:
from openpype.tools.ayon_launcher.ui import LauncherWindow
else:
from openpype.tools.launcher import LauncherWindow
self._window = LauncherWindow()
def _show_launcher(self):
if self._window is None:
return
self._window.show()
self._window.raise_()
self._window.activateWindow()

View file

@ -20,7 +20,13 @@ class LauncherAction(object):
log.propagate = True
def is_compatible(self, session):
"""Return whether the class is compatible with the Session."""
"""Return whether the class is compatible with the Session.
Args:
session (dict[str, Union[str, None]]): Session data with
AVALON_PROJECT, AVALON_ASSET and AVALON_TASK.
"""
return True
def process(self, session, **kwargs):

View file

@ -40,6 +40,7 @@ from .lib import (
apply_plugin_settings_automatically,
get_plugin_settings,
get_publish_instance_label,
get_publish_instance_families,
)
from .abstract_expected_files import ExpectedFiles
@ -87,6 +88,7 @@ __all__ = (
"apply_plugin_settings_automatically",
"get_plugin_settings",
"get_publish_instance_label",
"get_publish_instance_families",
"ExpectedFiles",

View file

@ -1002,3 +1002,27 @@ def get_publish_instance_label(instance):
or instance.data.get("name")
or str(instance)
)
def get_publish_instance_families(instance):
"""Get all families of the instance.
Look for families under 'family' and 'families' keys in instance data.
Value of 'family' is used as first family and then all other families
in random order.
Args:
pyblish.api.Instance: Instance to get families from.
Returns:
list[str]: List of families.
"""
family = instance.data.get("family")
families = set(instance.data.get("families") or [])
output = []
if family:
output.append(family)
families.discard(family)
output.extend(families)
return output

View file

@ -103,13 +103,16 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
# stash render job id for later validation
instance.data["render_job_id"] = data.get("job").get("_id")
staging_dir_persistent = instance.data.get(
"stagingDir_persistent", False
)
representations = []
for repre_data in instance_data.get("representations") or []:
self._fill_staging_dir(repre_data, anatomy)
representations.append(repre_data)
add_repre_files_for_cleanup(instance, repre_data)
if not staging_dir_persistent:
add_repre_files_for_cleanup(instance, repre_data)
instance.data["representations"] = representations
@ -124,6 +127,8 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
self.log.debug(
f"Adding audio to instance: {instance.data['audio']}")
return staging_dir_persistent
def process(self, context):
self._context = context
@ -160,9 +165,12 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
legacy_io.Session.update(session_data)
os.environ.update(session_data)
session_is_set = True
self._process_path(data, anatomy)
context.data["cleanupFullPaths"].append(path)
context.data["cleanupEmptyDirs"].append(os.path.dirname(path))
staging_dir_persistent = self._process_path(data, anatomy)
if not staging_dir_persistent:
context.data["cleanupFullPaths"].append(path)
context.data["cleanupEmptyDirs"].append(
os.path.dirname(path)
)
except Exception as e:
self.log.error(e, exc_info=True)
raise Exception("Error") from e

View file

@ -1,7 +1,11 @@
import collections
import pyblish.api
from ayon_api import create_link, make_sure_link_type_exists
from ayon_api import (
create_link,
make_sure_link_type_exists,
get_versions_links,
)
from openpype import AYON_SERVER_ENABLED
@ -124,6 +128,33 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin):
version_entity["_id"],
)
def _get_existing_links(self, project_name, link_type, entity_ids):
"""Find all existing links for given version ids.
Args:
project_name (str): Name of project.
link_type (str): Type of link.
entity_ids (set[str]): Set of version ids.
Returns:
dict[str, set[str]]: Existing links by version id.
"""
output = collections.defaultdict(set)
if not entity_ids:
return output
existing_in_links = get_versions_links(
project_name, entity_ids, [link_type], "output"
)
for entity_id, links in existing_in_links.items():
if not links:
continue
for link in links:
output[entity_id].add(link["entityId"])
return output
def create_links_on_server(self, context, new_links):
"""Create new links on server.
@ -144,16 +175,32 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin):
# Create link themselves
for link_type, items in new_links.items():
mapping = collections.defaultdict(set)
# Make sure there are no duplicates of src > dst ids
for item in items:
input_id, output_id = item
create_link(
project_name,
link_type,
input_id,
"version",
output_id,
"version"
)
_input_id, _output_id = item
mapping[_input_id].add(_output_id)
existing_links_by_in_id = self._get_existing_links(
project_name, link_type, set(mapping.keys())
)
for input_id, output_ids in mapping.items():
existing_links = existing_links_by_in_id[input_id]
for output_id in output_ids:
# Skip creation of link if already exists
# NOTE: AYON server does not support
# to have same links
if output_id in existing_links:
continue
create_link(
project_name,
link_type,
input_id,
"version",
output_id,
"version"
)
if not AYON_SERVER_ENABLED:

View file

@ -1102,7 +1102,7 @@ def _convert_global_project_settings(ayon_settings, output, default_settings):
"studio_name",
"studio_code",
):
ayon_core.pop(key)
ayon_core.pop(key, None)
# Publish conversion
ayon_publish = ayon_core["publish"]
@ -1140,6 +1140,27 @@ def _convert_global_project_settings(ayon_settings, output, default_settings):
profile["outputs"] = new_outputs
# ExtractOIIOTranscode plugin
extract_oiio_transcode = ayon_publish["ExtractOIIOTranscode"]
extract_oiio_transcode_profiles = extract_oiio_transcode["profiles"]
for profile in extract_oiio_transcode_profiles:
new_outputs = {}
name_counter = {}
for output in profile["outputs"]:
if "name" in output:
name = output.pop("name")
else:
# Backwards compatibility for setting without 'name' in model
name = output["extension"]
if name in new_outputs:
name_counter[name] += 1
name = "{}_{}".format(name, name_counter[name])
else:
name_counter[name] = 0
new_outputs[name] = output
profile["outputs"] = new_outputs
# Extract Burnin plugin
extract_burnin = ayon_publish["ExtractBurnin"]
extract_burnin_options = extract_burnin["options"]

View file

@ -19,6 +19,19 @@
],
"ext": ".ass"
},
"CreateStaticMesh": {
"enabled": true,
"default_variants": [
"Main"
],
"static_mesh_prefix": "S",
"collision_prefixes": [
"UBX",
"UCP",
"USP",
"UCX"
]
},
"CreateAlembicCamera": {
"enabled": true,
"default_variants": [
@ -102,6 +115,21 @@
"enabled": true,
"optional": true,
"active": true
},
"ValidateSubsetName": {
"enabled": true,
"optional": true,
"active": true
},
"ValidateMeshIsStatic": {
"enabled": true,
"optional": true,
"active": true
},
"ValidateUnrealStaticMeshName": {
"enabled": false,
"optional": true,
"active": true
}
}
}

View file

@ -39,6 +39,37 @@
]
},
{
"type": "dict",
"collapsible": true,
"key": "CreateStaticMesh",
"label": "Create Static Mesh",
"checkbox_key": "enabled",
"children": [
{
"type": "boolean",
"key": "enabled",
"label": "Enabled"
},
{
"type": "list",
"key": "default_variants",
"label": "Default Variants",
"object_type": "text"
},
{
"type": "text",
"key": "static_mesh_prefix",
"label": "Static Mesh Prefix"
},
{
"type": "list",
"key": "collision_prefixes",
"label": "Collision Mesh Prefixes",
"object_type": "text"
}
]
},
{
"type": "schema_template",
"name": "template_create_plugin",

View file

@ -47,6 +47,18 @@
{
"key": "ValidateContainers",
"label": "ValidateContainers"
},
{
"key": "ValidateSubsetName",
"label": "Validate Subset Name"
},
{
"key": "ValidateMeshIsStatic",
"label": "Validate Mesh is Static"
},
{
"key": "ValidateUnrealStaticMeshName",
"label": "Validate Unreal Static Mesh Name"
}
]
}

View file

@ -12,7 +12,7 @@
{
"type": "text",
"key": "default_render_image_folder",
"label": "Default render image folder"
"label": "Default render image folder. This setting can be\noverwritten by custom staging directory profile;\n\"project_settings/global/tools/publish\n/custom_staging_dir_profiles\"."
},
{
"type": "boolean",

View file

@ -18,8 +18,6 @@ def test_backward_compatibility(printer):
from openpype.lib import get_ffprobe_streams
from openpype.hosts.fusion.lib import switch_item
from openpype.lib import source_hash
from openpype.lib import run_subprocess

View file

@ -0,0 +1,297 @@
from abc import ABCMeta, abstractmethod
import six
@six.add_metaclass(ABCMeta)
class AbstractLauncherCommon(object):
@abstractmethod
def register_event_callback(self, topic, callback):
"""Register event callback.
Listen for events with given topic.
Args:
topic (str): Name of topic.
callback (Callable): Callback that will be called when event
is triggered.
"""
pass
class AbstractLauncherBackend(AbstractLauncherCommon):
@abstractmethod
def emit_event(self, topic, data=None, source=None):
"""Emit event.
Args:
topic (str): Event topic used for callbacks filtering.
data (Optional[dict[str, Any]]): Event data.
source (Optional[str]): Event source.
"""
pass
@abstractmethod
def get_project_settings(self, project_name):
"""Project settings for current project.
Args:
project_name (Union[str, None]): Project name.
Returns:
dict[str, Any]: Project settings.
"""
pass
@abstractmethod
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.
"""
pass
@abstractmethod
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:
dict[str, Any]: Folder entity data.
"""
pass
@abstractmethod
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:
dict[str, Any]: Task entity data.
"""
pass
class AbstractLauncherFrontEnd(AbstractLauncherCommon):
# Entity items for UI
@abstractmethod
def get_project_items(self, sender=None):
"""Project items for all projects.
This function may trigger events 'projects.refresh.started' and
'projects.refresh.finished' which will contain 'sender' value in data.
That may help to avoid re-refresh of project items in UI elements.
Args:
sender (str): Who requested folder items.
Returns:
list[ProjectItem]: Minimum possible information needed
for visualisation of folder hierarchy.
"""
pass
@abstractmethod
def get_folder_items(self, project_name, sender=None):
"""Folder items to visualize project hierarchy.
This function may trigger events 'folders.refresh.started' and
'folders.refresh.finished' which will contain 'sender' value in data.
That may help to avoid re-refresh of folder items in UI elements.
Args:
project_name (str): Project name.
sender (str): Who requested folder items.
Returns:
list[FolderItem]: Minimum possible information needed
for visualisation of folder hierarchy.
"""
pass
@abstractmethod
def get_task_items(self, project_name, folder_id, sender=None):
"""Task items.
This function may trigger events 'tasks.refresh.started' and
'tasks.refresh.finished' which will contain 'sender' value in data.
That may help to avoid re-refresh of task items in UI elements.
Args:
project_name (str): Project name.
folder_id (str): Folder ID for which are tasks requested.
sender (str): Who requested folder items.
Returns:
list[TaskItem]: Minimum possible information needed
for visualisation of tasks.
"""
pass
@abstractmethod
def get_selected_project_name(self):
"""Selected project name.
Returns:
Union[str, None]: Selected project name.
"""
pass
@abstractmethod
def get_selected_folder_id(self):
"""Selected folder id.
Returns:
Union[str, None]: Selected folder id.
"""
pass
@abstractmethod
def get_selected_task_id(self):
"""Selected task id.
Returns:
Union[str, None]: Selected task id.
"""
pass
@abstractmethod
def get_selected_task_name(self):
"""Selected task name.
Returns:
Union[str, None]: Selected task name.
"""
pass
@abstractmethod
def get_selected_context(self):
"""Get whole selected context.
Example:
{
"project_name": self.get_selected_project_name(),
"folder_id": self.get_selected_folder_id(),
"task_id": self.get_selected_task_id(),
"task_name": self.get_selected_task_name(),
}
Returns:
dict[str, Union[str, None]]: Selected context.
"""
pass
@abstractmethod
def set_selected_project(self, project_name):
"""Change selected folder.
Args:
project_name (Union[str, None]): Project nameor None if no project
is selected.
"""
pass
@abstractmethod
def set_selected_folder(self, folder_id):
"""Change selected folder.
Args:
folder_id (Union[str, None]): Folder id or None if no folder
is selected.
"""
pass
@abstractmethod
def set_selected_task(self, task_id, task_name):
"""Change selected task.
Args:
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
is selected.
"""
pass
# Actions
@abstractmethod
def get_action_items(self, project_name, folder_id, task_id):
"""Get action items for given context.
Args:
project_name (Union[str, None]): Project name.
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
Returns:
list[ActionItem]: List of action items that should be shown
for given context.
"""
pass
@abstractmethod
def trigger_action(self, project_name, folder_id, task_id, action_id):
"""Trigger action on given context.
Args:
project_name (Union[str, None]): Project name.
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
action_id (str): Action identifier.
"""
pass
@abstractmethod
def set_application_force_not_open_workfile(
self, project_name, folder_id, task_id, action_id, enabled
):
"""This is application action related to force not open last workfile.
Args:
project_name (Union[str, None]): Project name.
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
action_id (str): Action identifier.
enabled (bool): New value of force not open workfile.
"""
pass
@abstractmethod
def refresh(self):
"""Refresh everything, models, ui etc.
Triggers 'controller.refresh.started' event at the beginning and
'controller.refresh.finished' at the end.
"""
pass

View file

@ -0,0 +1,149 @@
from openpype.lib import Logger
from openpype.lib.events import QueuedEventSystem
from openpype.settings import get_project_settings
from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel
from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend
from .models import LauncherSelectionModel, ActionsModel
class BaseLauncherController(
AbstractLauncherFrontEnd, AbstractLauncherBackend
):
def __init__(self):
self._project_settings = {}
self._event_system = None
self._log = None
self._selection_model = LauncherSelectionModel(self)
self._projects_model = ProjectsModel(self)
self._hierarchy_model = HierarchyModel(self)
self._actions_model = ActionsModel(self)
@property
def log(self):
if self._log is None:
self._log = Logger.get_logger(self.__class__.__name__)
return self._log
@property
def event_system(self):
"""Inner event system for workfiles tool controller.
Is used for communication with UI. Event system is created on demand.
Returns:
QueuedEventSystem: Event system which can trigger callbacks
for topics.
"""
if self._event_system is None:
self._event_system = QueuedEventSystem()
return self._event_system
# ---------------------------------
# Implementation of abstract methods
# ---------------------------------
# Events system
def emit_event(self, topic, data=None, source=None):
"""Use implemented event system to trigger event."""
if data is None:
data = {}
self.event_system.emit(topic, data, source)
def register_event_callback(self, topic, callback):
self.event_system.add_callback(topic, callback)
# Entity items for UI
def get_project_items(self, sender=None):
return self._projects_model.get_project_items(sender)
def get_folder_items(self, project_name, sender=None):
return self._hierarchy_model.get_folder_items(project_name, sender)
def get_task_items(self, project_name, folder_id, sender=None):
return self._hierarchy_model.get_task_items(
project_name, folder_id, sender)
# Project settings for applications actions
def get_project_settings(self, project_name):
if project_name in self._project_settings:
return self._project_settings[project_name]
settings = get_project_settings(project_name)
self._project_settings[project_name] = settings
return settings
# Entity for backend
def get_project_entity(self, project_name):
return self._projects_model.get_project_entity(project_name)
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, project_name, task_id):
return self._hierarchy_model.get_task_entity(project_name, task_id)
# Selection methods
def get_selected_project_name(self):
return self._selection_model.get_selected_project_name()
def set_selected_project(self, project_name):
self._selection_model.set_selected_project(project_name)
def get_selected_folder_id(self):
return self._selection_model.get_selected_folder_id()
def set_selected_folder(self, folder_id):
self._selection_model.set_selected_folder(folder_id)
def get_selected_task_id(self):
return self._selection_model.get_selected_task_id()
def get_selected_task_name(self):
return self._selection_model.get_selected_task_name()
def set_selected_task(self, task_id, task_name):
self._selection_model.set_selected_task(task_id, task_name)
def get_selected_context(self):
return {
"project_name": self.get_selected_project_name(),
"folder_id": self.get_selected_folder_id(),
"task_id": self.get_selected_task_id(),
"task_name": self.get_selected_task_name(),
}
# Actions
def get_action_items(self, project_name, folder_id, task_id):
return self._actions_model.get_action_items(
project_name, folder_id, task_id)
def set_application_force_not_open_workfile(
self, project_name, folder_id, task_id, action_id, enabled
):
self._actions_model.set_application_force_not_open_workfile(
project_name, folder_id, task_id, action_id, enabled
)
def trigger_action(self, project_name, folder_id, task_id, identifier):
self._actions_model.trigger_action(
project_name, folder_id, task_id, identifier)
# General methods
def refresh(self):
self._emit_event("controller.refresh.started")
self._project_settings = {}
self._projects_model.reset()
self._hierarchy_model.reset()
self._actions_model.refresh()
self._projects_model.refresh()
self._emit_event("controller.refresh.finished")
def _emit_event(self, topic, data=None):
self.emit_event(topic, data, "controller")

View file

@ -0,0 +1,8 @@
from .actions import ActionsModel
from .selection import LauncherSelectionModel
__all__ = (
"ActionsModel",
"LauncherSelectionModel",
)

View file

@ -0,0 +1,505 @@
import os
from openpype import resources
from openpype.lib import Logger, OpenPypeSettingsRegistry
from openpype.pipeline.actions import (
discover_launcher_actions,
LauncherAction,
)
# class Action:
# def __init__(self, label, icon=None, identifier=None):
# self._label = label
# self._icon = icon
# self._callbacks = []
# self._identifier = identifier or uuid.uuid4().hex
# self._checked = True
# self._checkable = False
#
# def set_checked(self, checked):
# self._checked = checked
#
# def set_checkable(self, checkable):
# self._checkable = checkable
#
# def set_label(self, label):
# self._label = label
#
# def add_callback(self, callback):
# self._callbacks = callback
#
#
# class Menu:
# def __init__(self, label, icon=None):
# self.label = label
# self.icon = icon
# self._actions = []
#
# def add_action(self, action):
# self._actions.append(action)
class ApplicationAction(LauncherAction):
"""Action to launch an application.
Application action based on 'ApplicationManager' system.
Handling of applications in launcher is not ideal and should be completely
redone from scratch. This is just a temporary solution to keep backwards
compatibility with OpenPype launcher.
Todos:
Move handling of errors to frontend.
"""
# Application object
application = None
# Action attributes
name = None
label = None
label_variant = None
group = None
icon = None
color = None
order = 0
data = {}
project_settings = {}
project_entities = {}
_log = None
required_session_keys = (
"AVALON_PROJECT",
"AVALON_ASSET",
"AVALON_TASK"
)
@property
def log(self):
if self._log is None:
self._log = Logger.get_logger(self.__class__.__name__)
return self._log
def is_compatible(self, session):
for key in self.required_session_keys:
if not session.get(key):
return False
project_name = session["AVALON_PROJECT"]
project_entity = self.project_entities[project_name]
apps = project_entity["attrib"].get("applications")
if not apps or self.application.full_name not in apps:
return False
project_settings = self.project_settings[project_name]
only_available = project_settings["applications"]["only_available"]
if only_available and not self.application.find_executable():
return False
return True
def _show_message_box(self, title, message, details=None):
from qtpy import QtWidgets, QtGui
from openpype import style
dialog = QtWidgets.QMessageBox()
icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
dialog.setWindowIcon(icon)
dialog.setStyleSheet(style.load_stylesheet())
dialog.setWindowTitle(title)
dialog.setText(message)
if details:
dialog.setDetailedText(details)
dialog.exec_()
def process(self, session, **kwargs):
"""Process the full Application action"""
from openpype.lib import (
ApplictionExecutableNotFound,
ApplicationLaunchFailed,
)
project_name = session["AVALON_PROJECT"]
asset_name = session["AVALON_ASSET"]
task_name = session["AVALON_TASK"]
try:
self.application.launch(
project_name=project_name,
asset_name=asset_name,
task_name=task_name,
**self.data
)
except ApplictionExecutableNotFound as exc:
details = exc.details
msg = exc.msg
log_msg = str(msg)
if details:
log_msg += "\n" + details
self.log.warning(log_msg)
self._show_message_box(
"Application executable not found", msg, details
)
except ApplicationLaunchFailed as exc:
msg = str(exc)
self.log.warning(msg, exc_info=True)
self._show_message_box("Application launch failed", msg)
class ActionItem:
"""Item representing single action to trigger.
Todos:
Get rid of application specific logic.
Args:
identifier (str): Unique identifier of action item.
label (str): Action label.
variant_label (Union[str, None]): Variant label, full label is
concatenated with space. Actions are grouped under single
action if it has same 'label' and have set 'variant_label'.
icon (dict[str, str]): Icon definition.
order (int): Action ordering.
is_application (bool): Is action application action.
force_not_open_workfile (bool): Force not open workfile. Application
related.
full_label (Optional[str]): Full label, if not set it is generated
from 'label' and 'variant_label'.
"""
def __init__(
self,
identifier,
label,
variant_label,
icon,
order,
is_application,
force_not_open_workfile,
full_label=None
):
self.identifier = identifier
self.label = label
self.variant_label = variant_label
self.icon = icon
self.order = order
self.is_application = is_application
self.force_not_open_workfile = force_not_open_workfile
self._full_label = full_label
def copy(self):
return self.from_data(self.to_data())
@property
def full_label(self):
if self._full_label is None:
if self.variant_label:
self._full_label = " ".join([self.label, self.variant_label])
else:
self._full_label = self.label
return self._full_label
def to_data(self):
return {
"identifier": self.identifier,
"label": self.label,
"variant_label": self.variant_label,
"icon": self.icon,
"order": self.order,
"is_application": self.is_application,
"force_not_open_workfile": self.force_not_open_workfile,
"full_label": self._full_label,
}
@classmethod
def from_data(cls, data):
return cls(**data)
def get_action_icon(action):
"""Get action icon info.
Args:
action (LacunherAction): Action instance.
Returns:
dict[str, str]: Icon info.
"""
icon = action.icon
if not icon:
return {
"type": "awesome-font",
"name": "fa.cube",
"color": "white"
}
if isinstance(icon, dict):
return icon
icon_path = resources.get_resource(icon)
if not os.path.exists(icon_path):
try:
icon_path = icon.format(resources.RESOURCES_DIR)
except Exception:
pass
if os.path.exists(icon_path):
return {
"type": "path",
"path": icon_path,
}
return {
"type": "awesome-font",
"name": icon,
"color": action.color or "white"
}
class ActionsModel:
"""Actions model.
Args:
controller (AbstractLauncherBackend): Controller instance.
"""
_not_open_workfile_reg_key = "force_not_open_workfile"
def __init__(self, controller):
self._controller = controller
self._log = None
self._discovered_actions = None
self._actions = None
self._action_items = {}
self._launcher_tool_reg = OpenPypeSettingsRegistry("launcher_tool")
@property
def log(self):
if self._log is None:
self._log = Logger.get_logger(self.__class__.__name__)
return self._log
def refresh(self):
self._discovered_actions = None
self._actions = None
self._action_items = {}
self._controller.emit_event("actions.refresh.started")
self._get_action_objects()
self._controller.emit_event("actions.refresh.finished")
def get_action_items(self, project_name, folder_id, task_id):
"""Get actions for project.
Args:
project_name (Union[str, None]): Project name.
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
Returns:
list[ActionItem]: List of actions.
"""
not_open_workfile_actions = self._get_no_last_workfile_for_context(
project_name, folder_id, task_id)
session = self._prepare_session(project_name, folder_id, task_id)
output = []
action_items = self._get_action_items(project_name)
for identifier, action in self._get_action_objects().items():
if not action.is_compatible(session):
continue
action_item = action_items[identifier]
# Handling of 'force_not_open_workfile' for applications
if action_item.is_application:
action_item = action_item.copy()
action_item.force_not_open_workfile = (
not_open_workfile_actions.get(identifier, False)
)
output.append(action_item)
return output
def set_application_force_not_open_workfile(
self, project_name, folder_id, task_id, action_id, enabled
):
no_workfile_reg_data = self._get_no_last_workfile_reg_data()
project_data = no_workfile_reg_data.setdefault(project_name, {})
folder_data = project_data.setdefault(folder_id, {})
task_data = folder_data.setdefault(task_id, {})
task_data[action_id] = enabled
self._launcher_tool_reg.set_item(
self._not_open_workfile_reg_key, no_workfile_reg_data
)
def trigger_action(self, project_name, folder_id, task_id, identifier):
session = self._prepare_session(project_name, folder_id, task_id)
failed = False
error_message = None
action_label = identifier
action_items = self._get_action_items(project_name)
try:
action = self._actions[identifier]
action_item = action_items[identifier]
action_label = action_item.full_label
self._controller.emit_event(
"action.trigger.started",
{
"identifier": identifier,
"full_label": action_label,
}
)
if isinstance(action, ApplicationAction):
per_action = self._get_no_last_workfile_for_context(
project_name, folder_id, task_id
)
force_not_open_workfile = per_action.get(identifier, False)
action.data["start_last_workfile"] = force_not_open_workfile
action.process(session)
except Exception as exc:
self.log.warning("Action trigger failed.", exc_info=True)
failed = True
error_message = str(exc)
self._controller.emit_event(
"action.trigger.finished",
{
"identifier": identifier,
"failed": failed,
"error_message": error_message,
"full_label": action_label,
}
)
def _get_no_last_workfile_reg_data(self):
try:
no_workfile_reg_data = self._launcher_tool_reg.get_item(
self._not_open_workfile_reg_key)
except ValueError:
no_workfile_reg_data = {}
self._launcher_tool_reg.set_item(
self._not_open_workfile_reg_key, no_workfile_reg_data)
return no_workfile_reg_data
def _get_no_last_workfile_for_context(
self, project_name, folder_id, task_id
):
not_open_workfile_reg_data = self._get_no_last_workfile_reg_data()
return (
not_open_workfile_reg_data
.get(project_name, {})
.get(folder_id, {})
.get(task_id, {})
)
def _prepare_session(self, project_name, folder_id, task_id):
folder_name = None
if folder_id:
folder = self._controller.get_folder_entity(
project_name, folder_id)
if folder:
folder_name = folder["name"]
task_name = None
if task_id:
task = self._controller.get_task_entity(project_name, task_id)
if task:
task_name = task["name"]
return {
"AVALON_PROJECT": project_name,
"AVALON_ASSET": folder_name,
"AVALON_TASK": task_name,
}
def _get_discovered_action_classes(self):
if self._discovered_actions is None:
self._discovered_actions = (
discover_launcher_actions()
+ self._get_applications_action_classes()
)
return self._discovered_actions
def _get_action_objects(self):
if self._actions is None:
actions = {}
for cls in self._get_discovered_action_classes():
obj = cls()
identifier = getattr(obj, "identifier", None)
if identifier is None:
identifier = cls.__name__
actions[identifier] = obj
self._actions = actions
return self._actions
def _get_action_items(self, project_name):
action_items = self._action_items.get(project_name)
if action_items is not None:
return action_items
project_entity = None
if project_name:
project_entity = self._controller.get_project_entity(project_name)
project_settings = self._controller.get_project_settings(project_name)
action_items = {}
for identifier, action in self._get_action_objects().items():
is_application = isinstance(action, ApplicationAction)
if is_application:
action.project_entities[project_name] = project_entity
action.project_settings[project_name] = project_settings
label = action.label or identifier
variant_label = getattr(action, "label_variant", None)
icon = get_action_icon(action)
item = ActionItem(
identifier,
label,
variant_label,
icon,
action.order,
is_application,
False
)
action_items[identifier] = item
self._action_items[project_name] = action_items
return action_items
def _get_applications_action_classes(self):
from openpype.lib.applications import (
CUSTOM_LAUNCH_APP_GROUPS,
ApplicationManager,
)
actions = []
manager = ApplicationManager()
for full_name, application in manager.applications.items():
if (
application.group.name in CUSTOM_LAUNCH_APP_GROUPS
or not application.enabled
):
continue
action = type(
"app_{}".format(full_name),
(ApplicationAction,),
{
"identifier": "application.{}".format(full_name),
"application": application,
"name": application.name,
"label": application.group.label,
"label_variant": application.label,
"group": None,
"icon": application.icon,
"color": getattr(application, "color", None),
"order": getattr(application, "order", None) or 0,
"data": {}
}
)
actions.append(action)
return actions

View file

@ -0,0 +1,72 @@
class LauncherSelectionModel(object):
"""Model handling selection changes.
Triggering events:
- "selection.project.changed"
- "selection.folder.changed"
- "selection.task.changed"
"""
event_source = "launcher.selection.model"
def __init__(self, controller):
self._controller = controller
self._project_name = None
self._folder_id = None
self._task_name = None
self._task_id = None
def get_selected_project_name(self):
return self._project_name
def set_selected_project(self, project_name):
if project_name == self._project_name:
return
self._project_name = project_name
self._controller.emit_event(
"selection.project.changed",
{"project_name": project_name},
self.event_source
)
def get_selected_folder_id(self):
return self._folder_id
def set_selected_folder(self, folder_id):
if folder_id == self._folder_id:
return
self._folder_id = folder_id
self._controller.emit_event(
"selection.folder.changed",
{
"project_name": self._project_name,
"folder_id": folder_id,
},
self.event_source
)
def get_selected_task_name(self):
return self._task_name
def get_selected_task_id(self):
return self._task_id
def set_selected_task(self, task_id, task_name):
if task_id == self._task_id:
return
self._task_name = task_name
self._task_id = task_id
self._controller.emit_event(
"selection.task.changed",
{
"project_name": self._project_name,
"folder_id": self._folder_id,
"task_name": task_name,
"task_id": task_id,
},
self.event_source
)

View file

@ -0,0 +1,6 @@
from .window import LauncherWindow
__all__ = (
"LauncherWindow",
)

View file

@ -0,0 +1,453 @@
import time
import collections
from qtpy import QtWidgets, QtCore, QtGui
from openpype.tools.flickcharm import FlickCharm
from openpype.tools.ayon_utils.widgets import get_qt_icon
from .resources import get_options_image_path
ANIMATION_LEN = 7
ACTION_ID_ROLE = QtCore.Qt.UserRole + 1
ACTION_IS_APPLICATION_ROLE = QtCore.Qt.UserRole + 2
ACTION_IS_GROUP_ROLE = QtCore.Qt.UserRole + 3
ACTION_SORT_ROLE = QtCore.Qt.UserRole + 4
ANIMATION_START_ROLE = QtCore.Qt.UserRole + 5
ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 6
FORCE_NOT_OPEN_WORKFILE_ROLE = QtCore.Qt.UserRole + 7
class ActionsQtModel(QtGui.QStandardItemModel):
"""Qt model for actions.
Args:
controller (AbstractLauncherFrontEnd): Controller instance.
"""
refreshed = QtCore.Signal()
def __init__(self, controller):
super(ActionsQtModel, self).__init__()
controller.register_event_callback(
"controller.refresh.finished",
self._on_controller_refresh_finished,
)
controller.register_event_callback(
"selection.project.changed",
self._on_selection_project_changed,
)
controller.register_event_callback(
"selection.folder.changed",
self._on_selection_folder_changed,
)
controller.register_event_callback(
"selection.task.changed",
self._on_selection_task_changed,
)
self._controller = controller
self._items_by_id = {}
self._groups_by_id = {}
self._selected_project_name = None
self._selected_folder_id = None
self._selected_task_id = None
def get_selected_project_name(self):
return self._selected_project_name
def get_selected_folder_id(self):
return self._selected_folder_id
def get_selected_task_id(self):
return self._selected_task_id
def get_group_items(self, action_id):
return self._groups_by_id[action_id]
def get_item_by_id(self, action_id):
return self._items_by_id.get(action_id)
def _clear_items(self):
self._items_by_id = {}
self._groups_by_id = {}
root = self.invisibleRootItem()
root.removeRows(0, root.rowCount())
def refresh(self):
items = self._controller.get_action_items(
self._selected_project_name,
self._selected_folder_id,
self._selected_task_id,
)
if not items:
self._clear_items()
self.refreshed.emit()
return
root_item = self.invisibleRootItem()
all_action_items_info = []
items_by_label = collections.defaultdict(list)
for item in items:
if not item.variant_label:
all_action_items_info.append((item, False))
else:
items_by_label[item.label].append(item)
groups_by_id = {}
for action_items in items_by_label.values():
first_item = next(iter(action_items))
all_action_items_info.append((first_item, len(action_items) > 1))
groups_by_id[first_item.identifier] = action_items
new_items = []
items_by_id = {}
for action_item_info in all_action_items_info:
action_item, is_group = action_item_info
icon = get_qt_icon(action_item.icon)
if is_group:
label = action_item.label
else:
label = action_item.full_label
item = self._items_by_id.get(action_item.identifier)
if item is None:
item = QtGui.QStandardItem()
item.setData(action_item.identifier, ACTION_ID_ROLE)
new_items.append(item)
item.setFlags(QtCore.Qt.ItemIsEnabled)
item.setData(label, QtCore.Qt.DisplayRole)
item.setData(icon, QtCore.Qt.DecorationRole)
item.setData(is_group, ACTION_IS_GROUP_ROLE)
item.setData(action_item.order, ACTION_SORT_ROLE)
item.setData(
action_item.is_application, ACTION_IS_APPLICATION_ROLE)
item.setData(
action_item.force_not_open_workfile,
FORCE_NOT_OPEN_WORKFILE_ROLE)
items_by_id[action_item.identifier] = item
if new_items:
root_item.appendRows(new_items)
to_remove = set(self._items_by_id.keys()) - set(items_by_id.keys())
for identifier in to_remove:
item = self._items_by_id.pop(identifier)
root_item.removeRow(item.row())
self._groups_by_id = groups_by_id
self._items_by_id = items_by_id
self.refreshed.emit()
def _on_controller_refresh_finished(self):
context = self._controller.get_selected_context()
self._selected_project_name = context["project_name"]
self._selected_folder_id = context["folder_id"]
self._selected_task_id = context["task_id"]
self.refresh()
def _on_selection_project_changed(self, event):
self._selected_project_name = event["project_name"]
self._selected_folder_id = None
self._selected_task_id = None
self.refresh()
def _on_selection_folder_changed(self, event):
self._selected_project_name = event["project_name"]
self._selected_folder_id = event["folder_id"]
self._selected_task_id = None
self.refresh()
def _on_selection_task_changed(self, event):
self._selected_project_name = event["project_name"]
self._selected_folder_id = event["folder_id"]
self._selected_task_id = event["task_id"]
self.refresh()
class ActionDelegate(QtWidgets.QStyledItemDelegate):
_cached_extender = {}
def __init__(self, *args, **kwargs):
super(ActionDelegate, self).__init__(*args, **kwargs)
self._anim_start_color = QtGui.QColor(178, 255, 246)
self._anim_end_color = QtGui.QColor(5, 44, 50)
def _draw_animation(self, painter, option, index):
grid_size = option.widget.gridSize()
x_offset = int(
(grid_size.width() / 2)
- (option.rect.width() / 2)
)
item_x = option.rect.x() - x_offset
rect_offset = grid_size.width() / 20
size = grid_size.width() - (rect_offset * 2)
anim_rect = QtCore.QRect(
item_x + rect_offset,
option.rect.y() + rect_offset,
size,
size
)
painter.save()
painter.setBrush(QtCore.Qt.transparent)
gradient = QtGui.QConicalGradient()
gradient.setCenter(QtCore.QPointF(anim_rect.center()))
gradient.setColorAt(0, self._anim_start_color)
gradient.setColorAt(1, self._anim_end_color)
time_diff = time.time() - index.data(ANIMATION_START_ROLE)
# Repeat 4 times
part_anim = 2.5
part_time = time_diff % part_anim
offset = (part_time / part_anim) * 360
angle = (offset + 90) % 360
gradient.setAngle(-angle)
pen = QtGui.QPen(QtGui.QBrush(gradient), rect_offset)
pen.setCapStyle(QtCore.Qt.RoundCap)
painter.setPen(pen)
painter.drawArc(
anim_rect,
-16 * (angle + 10),
-16 * offset
)
painter.restore()
@classmethod
def _get_extender_pixmap(cls, size):
pix = cls._cached_extender.get(size)
if pix is not None:
return pix
pix = QtGui.QPixmap(get_options_image_path()).scaled(
size, size,
QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation
)
cls._cached_extender[size] = pix
return pix
def paint(self, painter, option, index):
painter.setRenderHints(
QtGui.QPainter.Antialiasing
| QtGui.QPainter.SmoothPixmapTransform
)
if index.data(ANIMATION_STATE_ROLE):
self._draw_animation(painter, option, index)
super(ActionDelegate, self).paint(painter, option, index)
if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE):
rect = QtCore.QRectF(
option.rect.x(), option.rect.height(), 5, 5)
painter.setPen(QtCore.Qt.NoPen)
painter.setBrush(QtGui.QColor(200, 0, 0))
painter.drawEllipse(rect)
if not index.data(ACTION_IS_GROUP_ROLE):
return
grid_size = option.widget.gridSize()
x_offset = int(
(grid_size.width() / 2)
- (option.rect.width() / 2)
)
item_x = option.rect.x() - x_offset
tenth_size = int(grid_size.width() / 10)
extender_size = int(tenth_size * 2.4)
extender_x = item_x + tenth_size
extender_y = option.rect.y() + tenth_size
pix = self._get_extender_pixmap(extender_size)
painter.drawPixmap(extender_x, extender_y, pix)
class ActionsWidget(QtWidgets.QWidget):
def __init__(self, controller, parent):
super(ActionsWidget, self).__init__(parent)
self._controller = controller
view = QtWidgets.QListView(self)
view.setProperty("mode", "icon")
view.setObjectName("IconView")
view.setViewMode(QtWidgets.QListView.IconMode)
view.setResizeMode(QtWidgets.QListView.Adjust)
view.setSelectionMode(QtWidgets.QListView.NoSelection)
view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
view.setWrapping(True)
view.setGridSize(QtCore.QSize(70, 75))
view.setIconSize(QtCore.QSize(30, 30))
view.setSpacing(0)
view.setWordWrap(True)
# Make view flickable
flick = FlickCharm(parent=view)
flick.activateOn(view)
model = ActionsQtModel(controller)
proxy_model = QtCore.QSortFilterProxyModel()
proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
proxy_model.setSortRole(ACTION_SORT_ROLE)
proxy_model.setSourceModel(model)
view.setModel(proxy_model)
delegate = ActionDelegate(self)
view.setItemDelegate(delegate)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(view)
animation_timer = QtCore.QTimer()
animation_timer.setInterval(40)
animation_timer.timeout.connect(self._on_animation)
view.clicked.connect(self._on_clicked)
view.customContextMenuRequested.connect(self._on_context_menu)
model.refreshed.connect(self._on_model_refresh)
self._animated_items = set()
self._animation_timer = animation_timer
self._context_menu = None
self._flick = flick
self._view = view
self._model = model
self._proxy_model = proxy_model
self._set_row_height(1)
def _set_row_height(self, rows):
self.setMinimumHeight(rows * 75)
def _on_model_refresh(self):
self._proxy_model.sort(0)
def _on_animation(self):
time_now = time.time()
for action_id in tuple(self._animated_items):
item = self._model.get_item_by_id(action_id)
if item is None:
self._animated_items.discard(action_id)
continue
start_time = item.data(ANIMATION_START_ROLE)
if start_time is None or (time_now - start_time) > ANIMATION_LEN:
item.setData(0, ANIMATION_STATE_ROLE)
self._animated_items.discard(action_id)
if not self._animated_items:
self._animation_timer.stop()
self.update()
def _start_animation(self, index):
# Offset refresh timout
model_index = self._proxy_model.mapToSource(index)
if not model_index.isValid():
return
action_id = model_index.data(ACTION_ID_ROLE)
self._model.setData(model_index, time.time(), ANIMATION_START_ROLE)
self._model.setData(model_index, 1, ANIMATION_STATE_ROLE)
self._animated_items.add(action_id)
self._animation_timer.start()
def _on_context_menu(self, point):
"""Creates menu to force skip opening last workfile."""
index = self._view.indexAt(point)
if not index.isValid():
return
if not index.data(ACTION_IS_APPLICATION_ROLE):
return
menu = QtWidgets.QMenu(self._view)
checkbox = QtWidgets.QCheckBox(
"Skip opening last workfile.", menu)
if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE):
checkbox.setChecked(True)
action_id = index.data(ACTION_ID_ROLE)
checkbox.stateChanged.connect(
lambda: self._on_checkbox_changed(
action_id, checkbox.isChecked()
)
)
action = QtWidgets.QWidgetAction(menu)
action.setDefaultWidget(checkbox)
menu.addAction(action)
self._context_menu = menu
global_point = self.mapToGlobal(point)
menu.exec_(global_point)
self._context_menu = None
def _on_checkbox_changed(self, action_id, is_checked):
if self._context_menu is not None:
self._context_menu.close()
project_name = self._model.get_selected_project_name()
folder_id = self._model.get_selected_folder_id()
task_id = self._model.get_selected_task_id()
self._controller.set_application_force_not_open_workfile(
project_name, folder_id, task_id, action_id, is_checked)
self._model.refresh()
def _on_clicked(self, index):
if not index or not index.isValid():
return
is_group = index.data(ACTION_IS_GROUP_ROLE)
action_id = index.data(ACTION_ID_ROLE)
project_name = self._model.get_selected_project_name()
folder_id = self._model.get_selected_folder_id()
task_id = self._model.get_selected_task_id()
if not is_group:
self._controller.trigger_action(
project_name, folder_id, task_id, action_id
)
self._start_animation(index)
return
action_items = self._model.get_group_items(action_id)
menu = QtWidgets.QMenu(self)
actions_mapping = {}
for action_item in action_items:
menu_action = QtWidgets.QAction(action_item.full_label)
menu.addAction(menu_action)
actions_mapping[menu_action] = action_item
result = menu.exec_(QtGui.QCursor.pos())
if not result:
return
action_item = actions_mapping[result]
self._controller.trigger_action(
project_name, folder_id, task_id, action_item.identifier
)
self._start_animation(index)

View file

@ -0,0 +1,102 @@
import qtawesome
from qtpy import QtWidgets, QtCore
from openpype.tools.utils import (
PlaceholderLineEdit,
SquareButton,
RefreshButton,
)
from openpype.tools.ayon_utils.widgets import (
ProjectsCombobox,
FoldersWidget,
TasksWidget,
)
class HierarchyPage(QtWidgets.QWidget):
def __init__(self, controller, parent):
super(HierarchyPage, self).__init__(parent)
# Header
header_widget = QtWidgets.QWidget(self)
btn_back_icon = qtawesome.icon("fa.angle-left", color="white")
btn_back = SquareButton(header_widget)
btn_back.setIcon(btn_back_icon)
projects_combobox = ProjectsCombobox(controller, header_widget)
refresh_btn = RefreshButton(header_widget)
header_layout = QtWidgets.QHBoxLayout(header_widget)
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.addWidget(btn_back, 0)
header_layout.addWidget(projects_combobox, 1)
header_layout.addWidget(refresh_btn, 0)
# Body - Folders + Tasks selection
content_body = QtWidgets.QSplitter(self)
content_body.setContentsMargins(0, 0, 0, 0)
content_body.setSizePolicy(
QtWidgets.QSizePolicy.Expanding,
QtWidgets.QSizePolicy.Expanding
)
content_body.setOrientation(QtCore.Qt.Horizontal)
# - Folders widget with filter
folders_wrapper = QtWidgets.QWidget(content_body)
folders_filter_text = PlaceholderLineEdit(folders_wrapper)
folders_filter_text.setPlaceholderText("Filter folders...")
folders_widget = FoldersWidget(controller, folders_wrapper)
folders_wrapper_layout = QtWidgets.QVBoxLayout(folders_wrapper)
folders_wrapper_layout.setContentsMargins(0, 0, 0, 0)
folders_wrapper_layout.addWidget(folders_filter_text, 0)
folders_wrapper_layout.addWidget(folders_widget, 1)
# - Tasks widget
tasks_widget = TasksWidget(controller, content_body)
content_body.addWidget(folders_wrapper)
content_body.addWidget(tasks_widget)
content_body.setStretchFactor(0, 100)
content_body.setStretchFactor(1, 65)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(header_widget, 0)
main_layout.addWidget(content_body, 1)
btn_back.clicked.connect(self._on_back_clicked)
refresh_btn.clicked.connect(self._on_refreh_clicked)
folders_filter_text.textChanged.connect(self._on_filter_text_changed)
self._is_visible = False
self._controller = controller
self._btn_back = btn_back
self._projects_combobox = projects_combobox
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
# Post init
projects_combobox.set_listen_to_selection_change(self._is_visible)
def set_page_visible(self, visible, project_name=None):
if self._is_visible == visible:
return
self._is_visible = visible
self._projects_combobox.set_listen_to_selection_change(visible)
if visible and project_name:
self._projects_combobox.set_selection(project_name)
def _on_back_clicked(self):
self._controller.set_selected_project(None)
def _on_refreh_clicked(self):
self._controller.refresh()
def _on_filter_text_changed(self, text):
self._folders_widget.set_name_filer(text)

View file

@ -0,0 +1,135 @@
from qtpy import QtWidgets, QtCore
from openpype.tools.flickcharm import FlickCharm
from openpype.tools.utils import PlaceholderLineEdit, RefreshButton
from openpype.tools.ayon_utils.widgets import (
ProjectsModel,
ProjectSortFilterProxy,
)
from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER
class ProjectIconView(QtWidgets.QListView):
"""Styled ListView that allows to toggle between icon and list mode.
Toggling between the two modes is done by Right Mouse Click.
"""
IconMode = 0
ListMode = 1
def __init__(self, parent=None, mode=ListMode):
super(ProjectIconView, self).__init__(parent=parent)
# Workaround for scrolling being super slow or fast when
# toggling between the two visual modes
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
self.setObjectName("IconView")
self._mode = None
self.set_mode(mode)
def set_mode(self, mode):
if mode == self._mode:
return
self._mode = mode
if mode == self.IconMode:
self.setViewMode(QtWidgets.QListView.IconMode)
self.setResizeMode(QtWidgets.QListView.Adjust)
self.setWrapping(True)
self.setWordWrap(True)
self.setGridSize(QtCore.QSize(151, 90))
self.setIconSize(QtCore.QSize(50, 50))
self.setSpacing(0)
self.setAlternatingRowColors(False)
self.setProperty("mode", "icon")
self.style().polish(self)
self.verticalScrollBar().setSingleStep(30)
elif self.ListMode:
self.setProperty("mode", "list")
self.style().polish(self)
self.setViewMode(QtWidgets.QListView.ListMode)
self.setResizeMode(QtWidgets.QListView.Adjust)
self.setWrapping(False)
self.setWordWrap(False)
self.setIconSize(QtCore.QSize(20, 20))
self.setGridSize(QtCore.QSize(100, 25))
self.setSpacing(0)
self.setAlternatingRowColors(False)
self.verticalScrollBar().setSingleStep(34)
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.RightButton:
self.set_mode(int(not self._mode))
return super(ProjectIconView, self).mousePressEvent(event)
class ProjectsWidget(QtWidgets.QWidget):
"""Projects Page"""
def __init__(self, controller, parent=None):
super(ProjectsWidget, self).__init__(parent=parent)
header_widget = QtWidgets.QWidget(self)
projects_filter_text = PlaceholderLineEdit(header_widget)
projects_filter_text.setPlaceholderText("Filter projects...")
refresh_btn = RefreshButton(header_widget)
header_layout = QtWidgets.QHBoxLayout(header_widget)
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.addWidget(projects_filter_text, 1)
header_layout.addWidget(refresh_btn, 0)
projects_view = ProjectIconView(parent=self)
projects_view.setSelectionMode(QtWidgets.QListView.NoSelection)
flick = FlickCharm(parent=self)
flick.activateOn(projects_view)
projects_model = ProjectsModel(controller)
projects_proxy_model = ProjectSortFilterProxy()
projects_proxy_model.setSourceModel(projects_model)
projects_view.setModel(projects_proxy_model)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(header_widget, 0)
main_layout.addWidget(projects_view, 1)
projects_view.clicked.connect(self._on_view_clicked)
projects_filter_text.textChanged.connect(
self._on_project_filter_change)
refresh_btn.clicked.connect(self._on_refresh_clicked)
controller.register_event_callback(
"projects.refresh.finished",
self._on_projects_refresh_finished
)
self._controller = controller
self._projects_view = projects_view
self._projects_model = projects_model
self._projects_proxy_model = projects_proxy_model
def _on_view_clicked(self, index):
if index.isValid():
project_name = index.data(QtCore.Qt.DisplayRole)
self._controller.set_selected_project(project_name)
def _on_project_filter_change(self, text):
self._projects_proxy_model.setFilterFixedString(text)
def _on_refresh_clicked(self):
self._controller.refresh()
def _on_projects_refresh_finished(self, event):
if event["sender"] != PROJECTS_MODEL_SENDER:
self._projects_model.refresh()

View file

@ -0,0 +1,7 @@
import os
RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__))
def get_options_image_path():
return os.path.join(RESOURCES_DIR, "options.png")

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,295 @@
from qtpy import QtWidgets, QtCore, QtGui
from openpype import style
from openpype import resources
from openpype.tools.ayon_launcher.control import BaseLauncherController
from .projects_widget import ProjectsWidget
from .hierarchy_page import HierarchyPage
from .actions_widget import ActionsWidget
class LauncherWindow(QtWidgets.QWidget):
"""Launcher interface"""
message_interval = 5000
refresh_interval = 10000
page_side_anim_interval = 250
def __init__(self, controller=None, parent=None):
super(LauncherWindow, self).__init__(parent)
if controller is None:
controller = BaseLauncherController()
icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
self.setWindowIcon(icon)
self.setWindowTitle("Launcher")
self.setFocusPolicy(QtCore.Qt.StrongFocus)
self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False)
self.setStyleSheet(style.load_stylesheet())
# Allow minimize
self.setWindowFlags(
QtCore.Qt.Window
| QtCore.Qt.CustomizeWindowHint
| QtCore.Qt.WindowTitleHint
| QtCore.Qt.WindowMinimizeButtonHint
| QtCore.Qt.WindowCloseButtonHint
)
self._controller = controller
# Main content - Pages & Actions
content_body = QtWidgets.QSplitter(self)
# Pages
pages_widget = QtWidgets.QWidget(content_body)
# - First page - Projects
projects_page = ProjectsWidget(controller, pages_widget)
# - Second page - Hierarchy (folders & tasks)
hierarchy_page = HierarchyPage(controller, pages_widget)
pages_layout = QtWidgets.QHBoxLayout(pages_widget)
pages_layout.setContentsMargins(0, 0, 0, 0)
pages_layout.addWidget(projects_page, 1)
pages_layout.addWidget(hierarchy_page, 1)
# Actions
actions_widget = ActionsWidget(controller, content_body)
# Vertically split Pages and Actions
content_body.setContentsMargins(0, 0, 0, 0)
content_body.setSizePolicy(
QtWidgets.QSizePolicy.Expanding,
QtWidgets.QSizePolicy.Expanding
)
content_body.setOrientation(QtCore.Qt.Vertical)
content_body.addWidget(pages_widget)
content_body.addWidget(actions_widget)
# Set useful default sizes and set stretch
# for the pages so that is the only one that
# stretches on UI resize.
content_body.setStretchFactor(0, 10)
content_body.setSizes([580, 160])
# Footer
footer_widget = QtWidgets.QWidget(self)
# - Message label
message_label = QtWidgets.QLabel(footer_widget)
# action_history = ActionHistory(footer_widget)
# action_history.setStatusTip("Show Action History")
footer_layout = QtWidgets.QHBoxLayout(footer_widget)
footer_layout.setContentsMargins(0, 0, 0, 0)
footer_layout.addWidget(message_label, 1)
# footer_layout.addWidget(action_history, 0)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(content_body, 1)
layout.addWidget(footer_widget, 0)
message_timer = QtCore.QTimer()
message_timer.setInterval(self.message_interval)
message_timer.setSingleShot(True)
refresh_timer = QtCore.QTimer()
refresh_timer.setInterval(self.refresh_interval)
page_slide_anim = QtCore.QVariantAnimation(self)
page_slide_anim.setDuration(self.page_side_anim_interval)
page_slide_anim.setStartValue(0.0)
page_slide_anim.setEndValue(1.0)
page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad)
message_timer.timeout.connect(self._on_message_timeout)
refresh_timer.timeout.connect(self._on_refresh_timeout)
page_slide_anim.valueChanged.connect(
self._on_page_slide_value_changed)
page_slide_anim.finished.connect(self._on_page_slide_finished)
controller.register_event_callback(
"selection.project.changed",
self._on_project_selection_change,
)
controller.register_event_callback(
"action.trigger.started",
self._on_action_trigger_started,
)
controller.register_event_callback(
"action.trigger.finished",
self._on_action_trigger_finished,
)
self._controller = controller
self._is_on_projects_page = True
self._window_is_active = False
self._refresh_on_activate = False
self._pages_widget = pages_widget
self._pages_layout = pages_layout
self._projects_page = projects_page
self._hierarchy_page = hierarchy_page
self._actions_widget = actions_widget
self._message_label = message_label
# self._action_history = action_history
self._message_timer = message_timer
self._refresh_timer = refresh_timer
self._page_slide_anim = page_slide_anim
hierarchy_page.setVisible(not self._is_on_projects_page)
self.resize(520, 740)
def showEvent(self, event):
super(LauncherWindow, self).showEvent(event)
self._window_is_active = True
if not self._refresh_timer.isActive():
self._refresh_timer.start()
self._controller.refresh()
def closeEvent(self, event):
super(LauncherWindow, self).closeEvent(event)
self._window_is_active = False
self._refresh_timer.stop()
def changeEvent(self, event):
if event.type() in (
QtCore.QEvent.Type.WindowStateChange,
QtCore.QEvent.ActivationChange,
):
is_active = self.isActiveWindow() and not self.isMinimized()
self._window_is_active = is_active
if is_active and self._refresh_on_activate:
self._refresh_on_activate = False
self._on_refresh_timeout()
self._refresh_timer.start()
super(LauncherWindow, self).changeEvent(event)
def _on_refresh_timeout(self):
# Stop timer if widget is not visible
if self._window_is_active:
self._controller.refresh()
else:
self._refresh_on_activate = True
def _echo(self, message):
self._message_label.setText(str(message))
self._message_timer.start()
def _on_message_timeout(self):
self._message_label.setText("")
def _on_project_selection_change(self, event):
project_name = event["project_name"]
if not project_name:
self._go_to_projects_page()
elif self._is_on_projects_page:
self._go_to_hierarchy_page(project_name)
def _on_action_trigger_started(self, event):
self._echo("Running action: {}".format(event["full_label"]))
def _on_action_trigger_finished(self, event):
if not event["failed"]:
return
self._echo("Failed: {}".format(event["error_message"]))
def _is_page_slide_anim_running(self):
return (
self._page_slide_anim.state() == QtCore.QAbstractAnimation.Running
)
def _go_to_projects_page(self):
if self._is_on_projects_page:
return
self._is_on_projects_page = True
self._hierarchy_page.set_page_visible(False)
self._start_page_slide_animation()
def _go_to_hierarchy_page(self, project_name):
if not self._is_on_projects_page:
return
self._is_on_projects_page = False
self._hierarchy_page.set_page_visible(True, project_name)
self._start_page_slide_animation()
def _start_page_slide_animation(self):
if self._is_on_projects_page:
direction = QtCore.QAbstractAnimation.Backward
else:
direction = QtCore.QAbstractAnimation.Forward
self._page_slide_anim.setDirection(direction)
if self._is_page_slide_anim_running():
return
layout_spacing = self._pages_layout.spacing()
if self._is_on_projects_page:
hierarchy_geo = self._hierarchy_page.geometry()
projects_geo = QtCore.QRect(hierarchy_geo)
projects_geo.moveRight(
hierarchy_geo.left() - (layout_spacing + 1))
self._projects_page.setVisible(True)
else:
projects_geo = self._projects_page.geometry()
hierarchy_geo = QtCore.QRect(projects_geo)
hierarchy_geo.moveLeft(projects_geo.right() + layout_spacing)
self._hierarchy_page.setVisible(True)
while self._pages_layout.count():
self._pages_layout.takeAt(0)
self._projects_page.setGeometry(projects_geo)
self._hierarchy_page.setGeometry(hierarchy_geo)
self._page_slide_anim.start()
def _on_page_slide_value_changed(self, value):
layout_spacing = self._pages_layout.spacing()
content_width = self._pages_widget.width() - layout_spacing
content_height = self._pages_widget.height()
# Visible widths of other widgets
hierarchy_width = int(content_width * value)
hierarchy_geo = QtCore.QRect(
content_width - hierarchy_width, 0, content_width, content_height
)
projects_geo = QtCore.QRect(hierarchy_geo)
projects_geo.moveRight(hierarchy_geo.left() - (layout_spacing + 1))
self._projects_page.setGeometry(projects_geo)
self._hierarchy_page.setGeometry(hierarchy_geo)
def _on_page_slide_finished(self):
self._pages_layout.addWidget(self._projects_page, 1)
self._pages_layout.addWidget(self._hierarchy_page, 1)
self._projects_page.setVisible(self._is_on_projects_page)
self._hierarchy_page.setVisible(not self._is_on_projects_page)
# def _on_history_action(self, history_data):
# action, session = history_data
# app = QtWidgets.QApplication.instance()
# modifiers = app.keyboardModifiers()
#
# is_control_down = QtCore.Qt.ControlModifier & modifiers
# if is_control_down:
# # Revert to that "session" location
# self.set_session(session)
# else:
# # User is holding control, rerun the action
# self.run_action(action, session=session)

View file

@ -0,0 +1,29 @@
"""Backend models that can be used in controllers."""
from .cache import CacheItem, NestedCacheItem
from .projects import (
ProjectItem,
ProjectsModel,
PROJECTS_MODEL_SENDER,
)
from .hierarchy import (
FolderItem,
TaskItem,
HierarchyModel,
HIERARCHY_MODEL_SENDER,
)
__all__ = (
"CacheItem",
"NestedCacheItem",
"ProjectItem",
"ProjectsModel",
"PROJECTS_MODEL_SENDER",
"FolderItem",
"TaskItem",
"HierarchyModel",
"HIERARCHY_MODEL_SENDER",
)

View file

@ -0,0 +1,196 @@
import time
import collections
InitInfo = collections.namedtuple(
"InitInfo",
["default_factory", "lifetime"]
)
def _default_factory_func():
return None
class CacheItem:
"""Simple cache item with lifetime and default value.
Args:
default_factory (Optional[callable]): Function that returns default
value used on init and on reset.
lifetime (Optional[int]): Lifetime of the cache data in seconds.
"""
def __init__(self, default_factory=None, lifetime=None):
if lifetime is None:
lifetime = 120
self._lifetime = lifetime
self._last_update = None
if default_factory is None:
default_factory = _default_factory_func
self._default_factory = default_factory
self._data = default_factory()
@property
def is_valid(self):
"""Is cache valid to use.
Return:
bool: True if cache is valid, False otherwise.
"""
if self._last_update is None:
return False
return (time.time() - self._last_update) < self._lifetime
def set_lifetime(self, lifetime):
"""Change lifetime of cache item.
Args:
lifetime (int): Lifetime of the cache data in seconds.
"""
self._lifetime = lifetime
def set_invalid(self):
"""Set cache as invalid."""
self._last_update = None
def reset(self):
"""Set cache as invalid and reset data."""
self._last_update = None
self._data = self._default_factory()
def get_data(self):
"""Receive cached data.
Returns:
Any: Any data that are cached.
"""
return self._data
def update_data(self, data):
self._data = data
self._last_update = time.time()
class NestedCacheItem:
"""Helper for cached items stored in nested structure.
Example:
>>> cache = NestedCacheItem(levels=2)
>>> cache["a"]["b"].is_valid
False
>>> cache["a"]["b"].get_data()
None
>>> cache["a"]["b"] = 1
>>> cache["a"]["b"].is_valid
True
>>> cache["a"]["b"].get_data()
1
>>> cache.reset()
>>> cache["a"]["b"].is_valid
False
Args:
levels (int): Number of nested levels where read cache is stored.
default_factory (Optional[callable]): Function that returns default
value used on init and on reset.
lifetime (Optional[int]): Lifetime of the cache data in seconds.
_init_info (Optional[InitInfo]): Private argument. Init info for
nested cache where created from parent item.
"""
def __init__(
self, levels=1, default_factory=None, lifetime=None, _init_info=None
):
if levels < 1:
raise ValueError("Nested levels must be greater than 0")
self._data_by_key = {}
if _init_info is None:
_init_info = InitInfo(default_factory, lifetime)
self._init_info = _init_info
self._levels = levels
def __getitem__(self, key):
"""Get cached data.
Args:
key (str): Key of the cache item.
Returns:
Union[NestedCacheItem, CacheItem]: Cache item.
"""
cache = self._data_by_key.get(key)
if cache is None:
if self._levels > 1:
cache = NestedCacheItem(
levels=self._levels - 1,
_init_info=self._init_info
)
else:
cache = CacheItem(
self._init_info.default_factory,
self._init_info.lifetime
)
self._data_by_key[key] = cache
return cache
def __setitem__(self, key, value):
"""Update cached data.
Args:
key (str): Key of the cache item.
value (Any): Any data that are cached.
"""
if self._levels > 1:
raise AttributeError((
"{} does not support '__setitem__'. Lower nested level by {}"
).format(self.__class__.__name__, self._levels - 1))
cache = self[key]
cache.update_data(value)
def get(self, key):
"""Get cached data.
Args:
key (str): Key of the cache item.
Returns:
Union[NestedCacheItem, CacheItem]: Cache item.
"""
return self[key]
def reset(self):
"""Reset cache."""
self._data_by_key = {}
def set_lifetime(self, lifetime):
"""Change lifetime of all children cache items.
Args:
lifetime (int): Lifetime of the cache data in seconds.
"""
self._init_info.lifetime = lifetime
for cache in self._data_by_key.values():
cache.set_lifetime(lifetime)
@property
def is_valid(self):
"""Raise reasonable error when called on wront level.
Raises:
AttributeError: If called on nested cache item.
"""
raise AttributeError((
"{} does not support 'is_valid'. Lower nested level by '{}'"
).format(self.__class__.__name__, self._levels))

View file

@ -0,0 +1,340 @@
import collections
import contextlib
from abc import ABCMeta, abstractmethod
import ayon_api
import six
from openpype.style import get_default_entity_icon_color
from .cache import NestedCacheItem
HIERARCHY_MODEL_SENDER = "hierarchy.model"
@six.add_metaclass(ABCMeta)
class AbstractHierarchyController:
@abstractmethod
def emit_event(self, topic, data, source):
pass
class FolderItem:
"""Item representing folder entity on a server.
Folder can be a child of another folder or a project.
Args:
entity_id (str): Folder id.
parent_id (Union[str, None]): Parent folder id. If 'None' then project
is parent.
name (str): Name of folder.
label (str): Folder label.
icon_name (str): Name of icon from font awesome.
icon_color (str): Hex color string that will be used for icon.
"""
def __init__(
self, entity_id, parent_id, name, label, icon
):
self.entity_id = entity_id
self.parent_id = parent_id
self.name = name
if not icon:
icon = {
"type": "awesome-font",
"name": "fa.folder",
"color": get_default_entity_icon_color()
}
self.icon = icon
self.label = label or name
def to_data(self):
"""Converts folder item to data.
Returns:
dict[str, Any]: Folder item data.
"""
return {
"entity_id": self.entity_id,
"parent_id": self.parent_id,
"name": self.name,
"label": self.label,
"icon": self.icon,
}
@classmethod
def from_data(cls, data):
"""Re-creates folder item from data.
Args:
data (dict[str, Any]): Folder item data.
Returns:
FolderItem: Folder item.
"""
return cls(**data)
class TaskItem:
"""Task item representing task entity on a server.
Task is child of a folder.
Task item has label that is used for display in UI. The label is by
default using task name and type.
Args:
task_id (str): Task id.
name (str): Name of task.
task_type (str): Type of task.
parent_id (str): Parent folder id.
icon_name (str): Name of icon from font awesome.
icon_color (str): Hex color string that will be used for icon.
"""
def __init__(
self, task_id, name, task_type, parent_id, icon
):
self.task_id = task_id
self.name = name
self.task_type = task_type
self.parent_id = parent_id
if icon is None:
icon = {
"type": "awesome-font",
"name": "fa.male",
"color": get_default_entity_icon_color()
}
self.icon = icon
self._label = None
@property
def id(self):
"""Alias for task_id.
Returns:
str: Task id.
"""
return self.task_id
@property
def label(self):
"""Label of task item for UI.
Returns:
str: Label of task item.
"""
if self._label is None:
self._label = "{} ({})".format(self.name, self.task_type)
return self._label
def to_data(self):
"""Converts task item to data.
Returns:
dict[str, Any]: Task item data.
"""
return {
"task_id": self.task_id,
"name": self.name,
"parent_id": self.parent_id,
"task_type": self.task_type,
"icon": self.icon,
}
@classmethod
def from_data(cls, data):
"""Re-create task item from data.
Args:
data (dict[str, Any]): Task item data.
Returns:
TaskItem: Task item.
"""
return cls(**data)
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
))
return output
def _get_folder_item_from_hierarchy_item(item):
return FolderItem(
item["id"],
item["parentId"],
item["name"],
item["label"],
None
)
class HierarchyModel(object):
"""Model for project hierarchy items.
Hierarchy items are folders and tasks. Folders can have as parent another
folder or project. Tasks can have as parent only folder.
"""
def __init__(self, controller):
self._folders_items = NestedCacheItem(levels=1, default_factory=dict)
self._folders_by_id = NestedCacheItem(levels=2, default_factory=dict)
self._task_items = NestedCacheItem(levels=2, default_factory=dict)
self._tasks_by_id = NestedCacheItem(levels=2, default_factory=dict)
self._folders_refreshing = set()
self._tasks_refreshing = set()
self._controller = controller
def reset(self):
self._folders_items.reset()
self._folders_by_id.reset()
self._task_items.reset()
self._tasks_by_id.reset()
def refresh_project(self, project_name):
self._refresh_folders_cache(project_name)
def get_folder_items(self, project_name, sender):
if not self._folders_items[project_name].is_valid:
self._refresh_folders_cache(project_name, sender)
return self._folders_items[project_name].get_data()
def get_task_items(self, project_name, folder_id, sender):
if not project_name or not folder_id:
return []
task_cache = self._task_items[project_name][folder_id]
if not task_cache.is_valid:
self._refresh_tasks_cache(project_name, folder_id, sender)
return task_cache.get_data()
def get_folder_entity(self, project_name, folder_id):
cache = self._folders_by_id[project_name][folder_id]
if not cache.is_valid:
entity = None
if folder_id:
entity = ayon_api.get_folder_by_id(project_name, folder_id)
cache.update_data(entity)
return cache.get_data()
def get_task_entity(self, project_name, task_id):
cache = self._tasks_by_id[project_name][task_id]
if not cache.is_valid:
entity = None
if task_id:
entity = ayon_api.get_task_by_id(project_name, task_id)
cache.update_data(entity)
return cache.get_data()
@contextlib.contextmanager
def _folder_refresh_event_manager(self, project_name, sender):
self._folders_refreshing.add(project_name)
self._controller.emit_event(
"folders.refresh.started",
{"project_name": project_name, "sender": sender},
HIERARCHY_MODEL_SENDER
)
try:
yield
finally:
self._controller.emit_event(
"folders.refresh.finished",
{"project_name": project_name, "sender": sender},
HIERARCHY_MODEL_SENDER
)
self._folders_refreshing.remove(project_name)
@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,
},
HIERARCHY_MODEL_SENDER
)
try:
yield
finally:
self._controller.emit_event(
"tasks.refresh.finished",
{
"project_name": project_name,
"folder_id": folder_id,
"sender": sender,
},
HIERARCHY_MODEL_SENDER
)
self._tasks_refreshing.discard(folder_id)
def _refresh_folders_cache(self, project_name, sender=None):
if project_name in self._folders_refreshing:
return
with self._folder_refresh_event_manager(project_name, sender):
folder_items = self._query_folders(project_name)
self._folders_items[project_name].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, project_name, folder_id, sender=None):
if folder_id in self._tasks_refreshing:
return
with self._task_refresh_event_manager(
project_name, folder_id, sender
):
task_items = self._query_tasks(project_name, folder_id)
self._task_items[project_name][folder_id] = 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

@ -0,0 +1,145 @@
import contextlib
from abc import ABCMeta, abstractmethod
import ayon_api
import six
from openpype.style import get_default_entity_icon_color
from .cache import CacheItem
PROJECTS_MODEL_SENDER = "projects.model"
@six.add_metaclass(ABCMeta)
class AbstractHierarchyController:
@abstractmethod
def emit_event(self, topic, data, source):
pass
class ProjectItem:
"""Item representing folder entity on a server.
Folder can be a child of another folder or a project.
Args:
name (str): Project name.
active (Union[str, None]): Parent folder id. If 'None' then project
is parent.
"""
def __init__(self, name, active, icon=None):
self.name = name
self.active = active
if icon is None:
icon = {
"type": "awesome-font",
"name": "fa.map",
"color": get_default_entity_icon_color(),
}
self.icon = icon
def to_data(self):
"""Converts folder item to data.
Returns:
dict[str, Any]: Folder item data.
"""
return {
"name": self.name,
"active": self.active,
"icon": self.icon,
}
@classmethod
def from_data(cls, data):
"""Re-creates folder item from data.
Args:
data (dict[str, Any]): Folder item data.
Returns:
FolderItem: Folder item.
"""
return cls(**data)
def _get_project_items_from_entitiy(projects):
"""
Args:
projects (list[dict[str, Any]]): List of projects.
Returns:
ProjectItem: Project item.
"""
return [
ProjectItem(project["name"], project["active"])
for project in projects
]
class ProjectsModel(object):
def __init__(self, controller):
self._projects_cache = CacheItem(default_factory=dict)
self._project_items_by_name = {}
self._projects_by_name = {}
self._is_refreshing = False
self._controller = controller
def reset(self):
self._projects_cache.reset()
self._project_items_by_name = {}
self._projects_by_name = {}
def refresh(self):
self._refresh_projects_cache()
def get_project_items(self, sender):
if not self._projects_cache.is_valid:
self._refresh_projects_cache(sender)
return self._projects_cache.get_data()
def get_project_entity(self, project_name):
if project_name not in self._projects_by_name:
entity = None
if project_name:
entity = ayon_api.get_project(project_name)
self._projects_by_name[project_name] = entity
return self._projects_by_name[project_name]
@contextlib.contextmanager
def _project_refresh_event_manager(self, sender):
self._is_refreshing = True
self._controller.emit_event(
"projects.refresh.started",
{"sender": sender},
PROJECTS_MODEL_SENDER
)
try:
yield
finally:
self._controller.emit_event(
"projects.refresh.finished",
{"sender": sender},
PROJECTS_MODEL_SENDER
)
self._is_refreshing = False
def _refresh_projects_cache(self, sender=None):
if self._is_refreshing:
return
with self._project_refresh_event_manager(sender):
project_items = self._query_projects()
self._projects_cache.update_data(project_items)
def _query_projects(self):
projects = ayon_api.get_projects(fields=["name", "active"])
return _get_project_items_from_entitiy(projects)

View file

@ -0,0 +1,37 @@
from .projects_widget import (
# ProjectsWidget,
ProjectsCombobox,
ProjectsModel,
ProjectSortFilterProxy,
)
from .folders_widget import (
FoldersWidget,
FoldersModel,
)
from .tasks_widget import (
TasksWidget,
TasksModel,
)
from .utils import (
get_qt_icon,
RefreshThread,
)
__all__ = (
# "ProjectsWidget",
"ProjectsCombobox",
"ProjectsModel",
"ProjectSortFilterProxy",
"FoldersWidget",
"FoldersModel",
"TasksWidget",
"TasksModel",
"get_qt_icon",
"RefreshThread",
)

View file

@ -0,0 +1,364 @@
import collections
from qtpy import QtWidgets, QtGui, QtCore
from openpype.tools.utils import (
RecursiveSortFilterProxyModel,
DeselectableTreeView,
)
from .utils import RefreshThread, get_qt_icon
SENDER_NAME = "qt_folders_model"
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
ITEM_NAME_ROLE = QtCore.Qt.UserRole + 2
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._last_project_name = 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 set_project_name(self, project_name):
"""Refresh folders items.
Refresh start thread because it can cause that controller can
start query from database if folders are not cached.
"""
if not project_name:
self._last_project_name = project_name
self._current_refresh_thread = None
self._fill_items({})
return
self._is_refreshing = True
if self._last_project_name != project_name:
self.clear()
self._last_project_name = project_name
thread = self._refresh_threads.get(project_name)
if thread is not None:
self._current_refresh_thread = thread
return
thread = RefreshThread(
project_name,
self._controller.get_folder_items,
project_name,
SENDER_NAME
)
self._current_refresh_thread = thread
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.
"""
# Make sure to remove thread from '_refresh_threads' dict
thread = self._refresh_threads.pop(thread_id)
if (
self._current_refresh_thread is None
or thread_id != self._current_refresh_thread.id
):
return
self._fill_items(thread.get_result())
def _fill_items(self, folder_items_by_id):
if not folder_items_by_id:
if folder_items_by_id is not None:
self.clear()
self._is_refreshing = False
self.refreshed.emit()
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(dict)
for folder_item in folder_items_by_id.values():
(
folder_items_by_parent
[folder_item.parent_id]
[folder_item.entity_id]
) = folder_item
hierarchy_queue = collections.deque()
hierarchy_queue.append((self.invisibleRootItem(), None))
# Keep pointers to removed items until the refresh finishes
# - some children of the items could be moved and reused elsewhere
removed_items = []
while hierarchy_queue:
item = hierarchy_queue.popleft()
parent_item, parent_id = item
folder_items = folder_items_by_parent[parent_id]
items_by_id = {}
folder_ids_to_add = set(folder_items)
for row_idx in reversed(range(parent_item.rowCount())):
child_item = parent_item.child(row_idx)
child_id = child_item.data(ITEM_ID_ROLE)
if child_id in ids_to_remove:
removed_items.append(parent_item.takeRow(row_idx))
else:
items_by_id[child_id] = child_item
new_items = []
for item_id in folder_ids_to_add:
folder_item = folder_items[item_id]
item = 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 = get_qt_icon(folder_item.icon)
item.setData(item_id, ITEM_ID_ROLE)
item.setData(folder_item.name, ITEM_NAME_ROLE)
item.setData(folder_item.label, QtCore.Qt.DisplayRole)
item.setData(icon, QtCore.Qt.DecorationRole)
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, item_id))
if new_items:
parent_item.appendRows(new_items)
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.
Expected selection handling is disabled by default. If enabled, the
widget will handle the expected in predefined way. Widget is listening
to event 'expected_selection_changed' with expected event data below,
the same data must be available when called method
'get_expected_selection_data' on controller.
{
"folder": {
"current": bool, # Folder is what should be set now
"folder_id": Union[str, None], # Folder id that should be selected
},
...
}
Selection is confirmed by calling method 'expected_folder_selected' on
controller.
Args:
controller (AbstractWorkfilesFrontend): The control object.
parent (QtWidgets.QWidget): The parent widget.
handle_expected_selection (bool): If True, the widget will handle
the expected selection. Defaults to False.
"""
def __init__(self, controller, parent, handle_expected_selection=False):
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(
"selection.project.changed",
self._on_project_selection_change,
)
controller.register_event_callback(
"folders.refresh.finished",
self._on_folders_refresh_finished
)
controller.register_event_callback(
"controller.refresh.finished",
self._on_controller_refresh
)
controller.register_event_callback(
"expected_selection_changed",
self._on_expected_selection_change
)
selection_model = folders_view.selectionModel()
selection_model.selectionChanged.connect(self._on_selection_change)
folders_model.refreshed.connect(self._on_model_refresh)
self._controller = controller
self._folders_view = folders_view
self._folders_model = folders_model
self._folders_proxy_model = folders_proxy_model
self._handle_expected_selection = handle_expected_selection
self._expected_selection = None
def set_name_filer(self, name):
"""Set filter of folder name.
Args:
name (str): The string filter.
"""
self._folders_proxy_model.setFilterFixedString(name)
def _on_project_selection_change(self, event):
project_name = event["project_name"]
self._set_project_name(project_name)
def _set_project_name(self, project_name):
self._folders_model.set_project_name(project_name)
def _clear(self):
self._folders_model.clear()
def _on_folders_refresh_finished(self, event):
if event["sender"] != SENDER_NAME:
self._set_project_name(event["project_name"])
def _on_controller_refresh(self):
self._update_expected_selection()
def _on_model_refresh(self):
if self._expected_selection:
self._set_expected_selection()
self._folders_proxy_model.sort(0)
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)
# Expected selection handling
def _on_expected_selection_change(self, event):
self._update_expected_selection(event.data)
def _update_expected_selection(self, expected_data=None):
if not self._handle_expected_selection:
return
if expected_data is None:
expected_data = self._controller.get_expected_selection_data()
folder_data = expected_data.get("folder")
if not folder_data or not folder_data["current"]:
return
folder_id = folder_data["id"]
self._expected_selection = folder_id
if not self._folders_model.is_refreshing:
self._set_expected_selection()
def _set_expected_selection(self):
if not self._handle_expected_selection:
return
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)

View file

@ -0,0 +1,325 @@
from qtpy import QtWidgets, QtCore, QtGui
from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER
from .utils import RefreshThread, get_qt_icon
PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1
PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2
class ProjectsModel(QtGui.QStandardItemModel):
refreshed = QtCore.Signal()
def __init__(self, controller):
super(ProjectsModel, self).__init__()
self._controller = controller
self._project_items = {}
self._empty_item = None
self._empty_item_added = False
self._is_refreshing = False
self._refresh_thread = None
@property
def is_refreshing(self):
return self._is_refreshing
def refresh(self):
self._refresh()
def has_content(self):
return len(self._project_items) > 0
def _add_empty_item(self):
item = self._get_empty_item()
if not self._empty_item_added:
root_item = self.invisibleRootItem()
root_item.appendRow(item)
self._empty_item_added = True
def _remove_empty_item(self):
if not self._empty_item_added:
return
root_item = self.invisibleRootItem()
item = self._get_empty_item()
root_item.takeRow(item.row())
self._empty_item_added = False
def _get_empty_item(self):
if self._empty_item is None:
item = QtGui.QStandardItem("< No projects >")
item.setFlags(QtCore.Qt.NoItemFlags)
self._empty_item = item
return self._empty_item
def _refresh(self):
if self._is_refreshing:
return
self._is_refreshing = True
refresh_thread = RefreshThread(
"projects", self._query_project_items
)
refresh_thread.refresh_finished.connect(self._refresh_finished)
refresh_thread.start()
self._refresh_thread = refresh_thread
def _query_project_items(self):
return self._controller.get_project_items()
def _refresh_finished(self):
# TODO check if failed
result = self._refresh_thread.get_result()
self._refresh_thread = None
self._fill_items(result)
self._is_refreshing = False
self.refreshed.emit()
def _fill_items(self, project_items):
items_to_remove = set(self._project_items.keys())
new_items = []
for project_item in project_items:
project_name = project_item.name
items_to_remove.discard(project_name)
item = self._project_items.get(project_name)
if item is None:
item = QtGui.QStandardItem()
new_items.append(item)
icon = get_qt_icon(project_item.icon)
item.setData(project_name, QtCore.Qt.DisplayRole)
item.setData(icon, QtCore.Qt.DecorationRole)
item.setData(project_name, PROJECT_NAME_ROLE)
item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE)
self._project_items[project_name] = item
root_item = self.invisibleRootItem()
if new_items:
root_item.appendRows(new_items)
for project_name in items_to_remove:
item = self._project_items.pop(project_name)
root_item.removeRow(item.row())
if self.has_content():
self._remove_empty_item()
else:
self._add_empty_item()
class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
def __init__(self, *args, **kwargs):
super(ProjectSortFilterProxy, self).__init__(*args, **kwargs)
self._filter_inactive = True
# Disable case sensitivity
self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
def lessThan(self, left_index, right_index):
if left_index.data(PROJECT_NAME_ROLE) is None:
return True
if right_index.data(PROJECT_NAME_ROLE) is None:
return False
left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE)
right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE)
if right_is_active == left_is_active:
return super(ProjectSortFilterProxy, self).lessThan(
left_index, right_index
)
if left_is_active:
return True
return False
def filterAcceptsRow(self, source_row, source_parent):
index = self.sourceModel().index(source_row, 0, source_parent)
string_pattern = self.filterRegularExpression().pattern()
if (
self._filter_inactive
and not index.data(PROJECT_IS_ACTIVE_ROLE)
):
return False
if string_pattern:
project_name = index.data(PROJECT_IS_ACTIVE_ROLE)
if project_name is not None:
return string_pattern.lower() in project_name.lower()
return super(ProjectSortFilterProxy, self).filterAcceptsRow(
source_row, source_parent
)
def _custom_index_filter(self, index):
return bool(index.data(PROJECT_IS_ACTIVE_ROLE))
def is_active_filter_enabled(self):
return self._filter_inactive
def set_active_filter_enabled(self, value):
if self._filter_inactive == value:
return
self._filter_inactive = value
self.invalidateFilter()
class ProjectsCombobox(QtWidgets.QWidget):
def __init__(self, controller, parent, handle_expected_selection=False):
super(ProjectsCombobox, self).__init__(parent)
projects_combobox = QtWidgets.QComboBox(self)
combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox)
projects_combobox.setItemDelegate(combobox_delegate)
projects_model = ProjectsModel(controller)
projects_proxy_model = ProjectSortFilterProxy()
projects_proxy_model.setSourceModel(projects_model)
projects_combobox.setModel(projects_proxy_model)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(projects_combobox, 1)
projects_model.refreshed.connect(self._on_model_refresh)
controller.register_event_callback(
"projects.refresh.finished",
self._on_projects_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
)
projects_combobox.currentIndexChanged.connect(
self._on_current_index_changed
)
self._controller = controller
self._listen_selection_change = True
self._handle_expected_selection = handle_expected_selection
self._expected_selection = None
self._projects_combobox = projects_combobox
self._projects_model = projects_model
self._projects_proxy_model = projects_proxy_model
self._combobox_delegate = combobox_delegate
def refresh(self):
self._projects_model.refresh()
def set_selection(self, project_name):
"""Set selection to a given project.
Selection change is ignored if project is not found.
Args:
project_name (str): Name of project.
Returns:
bool: True if selection was changed, False otherwise. NOTE:
Selection may not be changed if project is not found, or if
project is already selected.
"""
idx = self._projects_combobox.findData(
project_name, PROJECT_NAME_ROLE)
if idx < 0:
return False
if idx != self._projects_combobox.currentIndex():
self._projects_combobox.setCurrentIndex(idx)
return True
return False
def set_listen_to_selection_change(self, listen):
"""Disable listening to changes of the selection.
Because combobox is triggering selection change when it's model
is refreshed, it's necessary to disable listening to selection for
some cases, e.g. when is on a different page of UI and should be just
refreshed.
Args:
listen (bool): Enable or disable listening to selection changes.
"""
self._listen_selection_change = listen
def get_current_project_name(self):
"""Name of selected project.
Returns:
Union[str, None]: Name of selected project, or None if no project
"""
idx = self._projects_combobox.currentIndex()
if idx < 0:
return None
return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE)
def _on_current_index_changed(self, idx):
if not self._listen_selection_change:
return
project_name = self._projects_combobox.itemData(
idx, PROJECT_NAME_ROLE)
self._controller.set_selected_project(project_name)
def _on_model_refresh(self):
self._projects_proxy_model.sort(0)
if self._expected_selection:
self._set_expected_selection()
def _on_projects_refresh_finished(self, event):
if event["sender"] != PROJECTS_MODEL_SENDER:
self._projects_model.refresh()
def _on_controller_refresh(self):
self._update_expected_selection()
# Expected selection handling
def _on_expected_selection_change(self, event):
self._update_expected_selection(event.data)
def _set_expected_selection(self):
if not self._handle_expected_selection:
return
project_name = self._expected_selection
if project_name is not None:
if project_name != self.get_current_project_name():
self.set_selection(project_name)
else:
# Fake project change
self._on_current_index_changed(
self._projects_combobox.currentIndex()
)
self._controller.expected_project_selected(project_name)
def _update_expected_selection(self, expected_data=None):
if not self._handle_expected_selection:
return
if expected_data is None:
expected_data = self._controller.get_expected_selection_data()
project_data = expected_data.get("project")
if (
not project_data
or not project_data["current"]
or project_data["selected"]
):
return
self._expected_selection = project_data["name"]
if not self._projects_model.is_refreshing:
self._set_expected_selection()
class ProjectsWidget(QtWidgets.QWidget):
# TODO implement
pass

View file

@ -0,0 +1,436 @@
from qtpy import QtWidgets, QtGui, QtCore
from openpype.style import get_disabled_entity_icon_color
from openpype.tools.utils import DeselectableTreeView
from .utils import RefreshThread, get_qt_icon
SENDER_NAME = "qt_tasks_model"
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
PARENT_ID_ROLE = QtCore.Qt.UserRole + 2
ITEM_NAME_ROLE = QtCore.Qt.UserRole + 3
TASK_TYPE_ROLE = QtCore.Qt.UserRole + 4
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_project_name = 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, project_name, folder_id):
"""Refresh tasks for folder.
Args:
project_name (Union[str]): Name of project.
folder_id (Union[str, None]): Folder id.
"""
self._refresh(project_name, 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_project_name(self):
"""Get last refreshed project name.
Returns:
Union[str, None]: Project name.
"""
return self._last_project_name
def get_last_folder_id(self):
"""Get last refreshed folder id.
Returns:
Union[str, None]: Folder id.
"""
return self._last_folder_id
def set_selected_project(self, project_name):
self._selected_project_name = project_name
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 = get_qt_icon({
"type": "awesome-font",
"name": "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 = get_qt_icon({
"type": "awesome-font",
"name": "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, project_name, folder_id):
self._is_refreshing = True
self._last_project_name = project_name
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 = self._refresh_threads.get(folder_id)
if thread is not None:
self._current_refresh_thread = thread
return
thread = RefreshThread(
folder_id,
self._controller.get_task_items,
project_name,
folder_id
)
self._current_refresh_thread = thread
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.
"""
# Make sure to remove thread from '_refresh_threads' dict
thread = self._refresh_threads.pop(thread_id)
if (
self._current_refresh_thread is None
or thread_id != self._current_refresh_thread.id
):
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 = get_qt_icon(task_item.icon)
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.
parent (QtWidgets.QWidget): Parent widget.
handle_expected_selection (Optional[bool]): Handle expected selection.
"""
def __init__(self, controller, parent, handle_expected_selection=False):
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._handle_expected_selection = handle_expected_selection
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(
event["project_name"], self._selected_folder_id
)
def _folder_selection_changed(self, event):
self._selected_folder_id = event["folder_id"]
self._tasks_model.refresh(
event["project_name"], 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 _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(task_id, task_name)
# Expected selection handling
def _on_expected_selection_change(self, event):
self._update_expected_selection(event.data)
def _set_expected_selection(self):
if not self._handle_expected_selection:
return False
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 _update_expected_selection(self, expected_data=None):
if not self._handle_expected_selection:
return
if expected_data is None:
expected_data = self._controller.get_expected_selection_data()
folder_data = expected_data.get("folder")
task_data = expected_data.get("task")
if (
not folder_data
or not task_data
or not task_data["current"]
):
return
folder_id = folder_data["id"]
self._expected_selection_data = {
"task_name": task_data["name"],
"folder_id": folder_id,
}
model_folder_id = self._tasks_model.get_last_folder_id()
if folder_id != model_folder_id or self._tasks_model.is_refreshing:
return
self._set_expected_selection()

View file

@ -0,0 +1,98 @@
import os
from functools import partial
from qtpy import QtCore, QtGui
from openpype.tools.utils.lib import get_qta_icon_by_name_and_color
class RefreshThread(QtCore.QThread):
refresh_finished = QtCore.Signal(str)
def __init__(self, thread_id, func, *args, **kwargs):
super(RefreshThread, self).__init__()
self._id = thread_id
self._callback = partial(func, *args, **kwargs)
self._exception = None
self._result = None
@property
def id(self):
return self._id
@property
def failed(self):
return self._exception is not None
def run(self):
try:
self._result = self._callback()
except Exception as exc:
self._exception = exc
self.refresh_finished.emit(self.id)
def get_result(self):
return self._result
class _IconsCache:
"""Cache for icons."""
_cache = {}
_default = None
@classmethod
def _get_cache_key(cls, icon_def):
parts = []
icon_type = icon_def["type"]
if icon_type == "path":
parts = [icon_type, icon_def["path"]]
elif icon_type == "awesome-font":
parts = [icon_type, icon_def["name"], icon_def["color"]]
return "|".join(parts)
@classmethod
def get_icon(cls, icon_def):
icon_type = icon_def["type"]
cache_key = cls._get_cache_key(icon_def)
cache = cls._cache.get(cache_key)
if cache is not None:
return cache
icon = None
if icon_type == "path":
path = icon_def["path"]
if os.path.exists(path):
icon = QtGui.QIcon(path)
elif icon_type == "awesome-font":
icon_name = icon_def["name"]
icon_color = icon_def["color"]
icon = get_qta_icon_by_name_and_color(icon_name, icon_color)
if icon is None:
icon = get_qta_icon_by_name_and_color(
"fa.{}".format(icon_name), icon_color)
if icon is None:
icon = cls.get_default()
cls._cache[cache_key] = icon
return icon
@classmethod
def get_default(cls):
pix = QtGui.QPixmap(1, 1)
pix.fill(QtCore.Qt.transparent)
return QtGui.QIcon(pix)
def get_qt_icon(icon_def):
"""Returns icon from cache or creates new one.
Args:
icon_def (dict[str, Any]): Icon definition.
Returns:
QtGui.QIcon: Icon.
"""
return _IconsCache.get_icon(icon_def)

View file

@ -442,6 +442,16 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
pass
@abstractmethod
def get_project_entity(self):
"""Get current project entity.
Returns:
dict[str, Any]: Project entity data.
"""
pass
@abstractmethod
def get_folder_entity(self, folder_id):
"""Get folder entity by id.

View file

@ -193,6 +193,9 @@ 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_folder_entity(self, folder_id):
return self._entities_model.get_folder_entity(folder_id)

View file

@ -77,8 +77,11 @@ 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 = {}
@ -90,6 +93,7 @@ class EntitiesModel(object):
self._controller = controller
def reset(self):
self._project_cache.set_invalid({})
self._folders_cache.set_invalid({})
self._tasks_cache = {}
@ -99,6 +103,13 @@ class EntitiesModel(object):
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)

View file

@ -43,13 +43,21 @@ def get_folder_template_data(folder):
}
def get_task_template_data(task):
def get_task_template_data(project_entity, task):
if not task:
return {}
short_name = None
task_type_name = task["taskType"]
for task_type_info in project_entity["config"]["taskTypes"]:
if task_type_info["name"] == task_type_name:
short_name = task_type_info["shortName"]
break
return {
"task": {
"name": task["name"],
"type": task["taskType"]
"type": task_type_name,
"short": short_name,
}
}
@ -145,12 +153,13 @@ class WorkareaModel:
self._fill_data_by_folder_id[folder_id] = fill_data
return copy.deepcopy(fill_data)
def _get_task_data(self, folder_id, task_id):
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)
if task:
task_data[task_id] = get_task_template_data(task)
task_data[task_id] = get_task_template_data(
project_entity, task)
return copy.deepcopy(task_data[task_id])
def _prepare_fill_data(self, folder_id, task_id):
@ -159,7 +168,8 @@ class WorkareaModel:
base_data = self._get_base_data()
folder_data = self._get_folder_data(folder_id)
task_data = self._get_task_data(folder_id, task_id)
project_entity = self._controller.get_project_entity()
task_data = self._get_task_data(project_entity, folder_id, task_id)
base_data.update(folder_data)
base_data.update(task_data)

View file

@ -1,8 +1,5 @@
import os
from qtpy import QtWidgets, QtGui
from openpype import PLUGINS_DIR
from openpype import style
from openpype import resources
from openpype.lib import (
@ -10,46 +7,7 @@ from openpype.lib import (
ApplictionExecutableNotFound,
ApplicationLaunchFailed
)
from openpype.pipeline import (
LauncherAction,
register_launcher_action_path,
)
def register_actions_from_paths(paths):
if not paths:
return
for path in paths:
if not path:
continue
if path.startswith("."):
print((
"BUG: Relative paths are not allowed for security reasons. {}"
).format(path))
continue
if not os.path.exists(path):
print("Path was not found: {}".format(path))
continue
register_launcher_action_path(path)
def register_config_actions():
"""Register actions from the configuration for Launcher"""
actions_dir = os.path.join(PLUGINS_DIR, "actions")
if os.path.exists(actions_dir):
register_actions_from_paths([actions_dir])
def register_environment_actions():
"""Register actions from AVALON_ACTIONS for Launcher."""
paths_str = os.environ.get("AVALON_ACTIONS") or ""
register_actions_from_paths(paths_str.split(os.pathsep))
from openpype.pipeline import LauncherAction
# TODO move to 'openpype.pipeline.actions'

View file

@ -15,6 +15,10 @@ from .widgets import (
IconButton,
PixmapButton,
SeparatorWidget,
VerticalExpandButton,
SquareButton,
RefreshButton,
GoToCurrentButton,
)
from .views import DeselectableTreeView
from .error_dialog import ErrorMessageBox
@ -60,6 +64,11 @@ __all__ = (
"PixmapButton",
"SeparatorWidget",
"VerticalExpandButton",
"SquareButton",
"RefreshButton",
"GoToCurrentButton",
"DeselectableTreeView",
"ErrorMessageBox",

View file

@ -6,10 +6,13 @@ import qtawesome
from openpype.style import (
get_objected_colors,
get_style_image_path
get_style_image_path,
get_default_tools_icon_color,
)
from openpype.lib.attribute_definitions import AbstractAttrDef
from .lib import get_qta_icon_by_name_and_color
log = logging.getLogger(__name__)
@ -777,3 +780,77 @@ class SeparatorWidget(QtWidgets.QFrame):
self._orientation = orientation
self._set_size(self._size)
def get_refresh_icon():
return get_qta_icon_by_name_and_color(
"fa.refresh", get_default_tools_icon_color()
)
def get_go_to_current_icon():
return get_qta_icon_by_name_and_color(
"fa.arrow-down", get_default_tools_icon_color()
)
class VerticalExpandButton(QtWidgets.QPushButton):
"""Button which is expanding vertically.
By default, button is a little bit smaller than other widgets like
QLineEdit. This button is expanding vertically to match size of
other widgets, next to it.
"""
def __init__(self, parent=None):
super(VerticalExpandButton, self).__init__(parent)
sp = self.sizePolicy()
sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum)
self.setSizePolicy(sp)
class SquareButton(QtWidgets.QPushButton):
"""Make button square shape.
Change width to match height on resize.
"""
def __init__(self, *args, **kwargs):
super(SquareButton, self).__init__(*args, **kwargs)
sp = self.sizePolicy()
sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum)
sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Minimum)
self.setSizePolicy(sp)
self._ideal_width = None
def showEvent(self, event):
super(SquareButton, self).showEvent(event)
self._ideal_width = self.height()
self.updateGeometry()
def resizeEvent(self, event):
super(SquareButton, self).resizeEvent(event)
self._ideal_width = self.height()
self.updateGeometry()
def sizeHint(self):
sh = super(SquareButton, self).sizeHint()
ideal_width = self._ideal_width
if ideal_width is None:
ideal_width = sh.height()
sh.setWidth(ideal_width)
return sh
class RefreshButton(VerticalExpandButton):
def __init__(self, parent=None):
super(RefreshButton, self).__init__(parent)
self.setIcon(get_refresh_icon())
class GoToCurrentButton(VerticalExpandButton):
def __init__(self, parent=None):
super(GoToCurrentButton, self).__init__(parent)
self.setIcon(get_go_to_current_icon())

View file

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

204
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "OpenPype"
version = "3.16.6" # OpenPype
version = "3.17.0" # OpenPype
description = "Open VFX and Animation pipeline with support."
authors = ["OpenPype Team <info@openpype.io>"]
license = "MIT License"
@ -36,7 +36,7 @@ appdirs = { git = "https://github.com/ActiveState/appdirs.git", branch = "master
blessed = "^1.17" # openpype terminal formatting
coolname = "*"
clique = "1.6.*"
Click = "^8"
Click = "7.1.2"
dnspython = "^2.1.0"
ftrack-python-api = "^2.3.3"
arrow = "^0.17"
@ -56,6 +56,7 @@ QtPy = "^2.3.0"
qtawesome = "0.7.3"
speedcopy = "^2.1"
six = "^1.15"
urllib3 = "1.26.16"
semver = "^2.13.0" # for version resolution
wsrpc_aiohttp = "^3.1.1" # websocket server
pywin32 = { version = "301", markers = "sys_platform == 'win32'" }

View file

@ -1,68 +0,0 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "openpype:application-1.0",
"description": "An application definition.",
"type": "object",
"additionalProperties": true,
"required": [
"schema",
"label",
"application_dir",
"executable"
],
"properties": {
"schema": {
"description": "Schema identifier for payload",
"type": "string"
},
"label": {
"description": "Nice name of application.",
"type": "string"
},
"application_dir": {
"description": "Name of directory used for application resources.",
"type": "string"
},
"executable": {
"description": "Name of callable executable, this is called to launch the application",
"type": "string"
},
"description": {
"description": "Description of application.",
"type": "string"
},
"environment": {
"description": "Key/value pairs for environment variables related to this application. Supports lists for paths, such as PYTHONPATH.",
"type": "object",
"items": {
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}}
]
}
},
"default_dirs": {
"type": "array",
"items": {
"type": "string"
}
},
"copy": {
"type": "object",
"patternProperties": {
"^.*$": {
"anyOf": [
{"type": "string"},
{"type": "null"}
]
}
},
"additionalProperties": false
}
}
}

View file

@ -1,35 +0,0 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "openpype:asset-1.0",
"description": "A unit of data",
"type": "object",
"additionalProperties": true,
"required": [
"schema",
"name",
"subsets"
],
"properties": {
"schema": {
"description": "Schema identifier for payload",
"type": "string"
},
"name": {
"description": "Name of directory",
"type": "string"
},
"subsets": {
"type": "array",
"items": {
"$ref": "subset.json"
}
}
},
"definitions": {}
}

View file

@ -1,55 +0,0 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "openpype:asset-2.0",
"description": "A unit of data",
"type": "object",
"additionalProperties": true,
"required": [
"schema",
"type",
"name",
"silo",
"data"
],
"properties": {
"schema": {
"description": "Schema identifier for payload",
"type": "string",
"enum": ["openpype:asset-2.0"],
"example": "openpype:asset-2.0"
},
"type": {
"description": "The type of document",
"type": "string",
"enum": ["asset"],
"example": "asset"
},
"parent": {
"description": "Unique identifier to parent document",
"example": "592c33475f8c1b064c4d1696"
},
"name": {
"description": "Name of asset",
"type": "string",
"pattern": "^[a-zA-Z0-9_.]*$",
"example": "Bruce"
},
"silo": {
"description": "Group or container of asset",
"type": "string",
"example": "assets"
},
"data": {
"description": "Document metadata",
"type": "object",
"example": {"key": "value"}
}
},
"definitions": {}
}

View file

@ -1,55 +0,0 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "openpype:asset-3.0",
"description": "A unit of data",
"type": "object",
"additionalProperties": true,
"required": [
"schema",
"type",
"name",
"data"
],
"properties": {
"schema": {
"description": "Schema identifier for payload",
"type": "string",
"enum": ["openpype:asset-3.0"],
"example": "openpype:asset-3.0"
},
"type": {
"description": "The type of document",
"type": "string",
"enum": ["asset"],
"example": "asset"
},
"parent": {
"description": "Unique identifier to parent document",
"example": "592c33475f8c1b064c4d1696"
},
"name": {
"description": "Name of asset",
"type": "string",
"pattern": "^[a-zA-Z0-9_.]*$",
"example": "Bruce"
},
"silo": {
"description": "Group or container of asset",
"type": "string",
"pattern": "^[a-zA-Z0-9_.]*$",
"example": "assets"
},
"data": {
"description": "Document metadata",
"type": "object",
"example": {"key": "value"}
}
},
"definitions": {}
}

View file

@ -1,85 +0,0 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "openpype:config-1.0",
"description": "A project configuration.",
"type": "object",
"additionalProperties": false,
"required": [
"tasks",
"apps"
],
"properties": {
"schema": {
"description": "Schema identifier for payload",
"type": "string"
},
"template": {
"type": "object",
"additionalProperties": false,
"patternProperties": {
"^.*$": {
"type": "string"
}
}
},
"tasks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"icon": {"type": "string"},
"group": {"type": "string"},
"label": {"type": "string"}
},
"required": ["name"]
}
},
"apps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"icon": {"type": "string"},
"group": {"type": "string"},
"label": {"type": "string"}
},
"required": ["name"]
}
},
"families": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"icon": {"type": "string"},
"label": {"type": "string"},
"hideFilter": {"type": "boolean"}
},
"required": ["name"]
}
},
"groups": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"icon": {"type": "string"},
"color": {"type": "string"},
"order": {"type": ["integer", "number"]}
},
"required": ["name"]
}
},
"copy": {
"type": "object"
}
}
}

Some files were not shown because too many files have changed in this diff Show more