Merge branch 'develop' into enhancement/OP-6943_Display-mode-or-Viewport-style-not-shown-correctly-when-creating-review-in-3dsMax

This commit is contained in:
Kayla Man 2023-10-18 12:20:16 +08:00
commit 1ffd1f2fed
97 changed files with 5496 additions and 969 deletions

View file

@ -35,6 +35,9 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
- 3.17.3-nightly.2
- 3.17.3-nightly.1
- 3.17.2
- 3.17.2-nightly.4
- 3.17.2-nightly.3
- 3.17.2-nightly.2
@ -132,9 +135,6 @@ body:
- 3.14.11-nightly.4
- 3.14.11-nightly.3
- 3.14.11-nightly.2
- 3.14.11-nightly.1
- 3.14.10
- 3.14.10-nightly.9
validations:
required: true
- type: dropdown

View file

@ -1,6 +1,477 @@
# Changelog
## [3.17.2](https://github.com/ynput/OpenPype/tree/3.17.2)
[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.1...3.17.2)
### **🆕 New features**
<details>
<summary>Maya: Add MayaPy application. <a href="https://github.com/ynput/OpenPype/pull/5705">#5705</a></summary>
This adds mayapy to the application to be launched from a task.
___
</details>
<details>
<summary>Feature: Copy resources when downloading last workfile <a href="https://github.com/ynput/OpenPype/pull/4944">#4944</a></summary>
When the last published workfile is downloaded as a prelaunch hook, all resource files referenced in the workfile representation are copied to the `resources` folder, which is inside the local workfile folder.
___
</details>
<details>
<summary>Blender: Deadline support <a href="https://github.com/ynput/OpenPype/pull/5438">#5438</a></summary>
Add Deadline support for Blender.
___
</details>
<details>
<summary>Fusion: implement toggle to use Deadline plugin FusionCmd <a href="https://github.com/ynput/OpenPype/pull/5678">#5678</a></summary>
Fusion 17 doesn't work in DL 10.3, but FusionCmd does. It might be probably better option as headless variant.Fusion plugin seems to be closing and reopening application when worker is running on artist machine, not so with FusionCmdAdded configuration to Project Settings for admin to select appropriate Deadline plugin:
___
</details>
<details>
<summary>Loader tool: Refactor loader tool (for AYON) <a href="https://github.com/ynput/OpenPype/pull/5729">#5729</a></summary>
Refactored loader tool to new tool. Separated backend and frontend logic. Refactored logic is AYON-centric and is used only in AYON mode, so it does not affect OpenPype. The tool is also replacing library loader.
___
</details>
### **🚀 Enhancements**
<details>
<summary>Maya: implement matchmove publishing <a href="https://github.com/ynput/OpenPype/pull/5445">#5445</a></summary>
Add possibility to export multiple cameras in single `matchmove` family instance, both in `abc` and `ma`.Exposed flag 'Keep image planes' to control export of image planes.
___
</details>
<details>
<summary>Maya: Add optional Fbx extractors in Rig and Animation family <a href="https://github.com/ynput/OpenPype/pull/5589">#5589</a></summary>
This PR allows user to export control rigs(optionally with mesh) and animated rig in fbx optionally by attaching the rig objects to the two newly introduced sets.
___
</details>
<details>
<summary>Maya: Optional Resolution Validator for Render <a href="https://github.com/ynput/OpenPype/pull/5693">#5693</a></summary>
Adding optional resolution validator for maya in render family, similar to the one in Max.It checks if the resolution in render setting aligns with that in setting from the db.
___
</details>
<details>
<summary>Use host's node uniqueness for instance id in new publisher <a href="https://github.com/ynput/OpenPype/pull/5490">#5490</a></summary>
Instead of writing `instance_id` as parm or attributes on the publish instances we can, for some hosts, just rely on a unique name or path within the scene to refer to that particular instance. By doing so we fix #4820 because upon duplicating such a publish instance using the host's (DCC) functionality the uniqueness for the duplicate is then already ensured instead of attributes remaining exact same value as where to were duplicated from, making `instance_id` a non-unique value.
___
</details>
<details>
<summary>Max: Implementation of OCIO configuration <a href="https://github.com/ynput/OpenPype/pull/5499">#5499</a></summary>
Resolve #5473 Implementation of OCIO configuration for Max 2024 regarding to the update of Max 2024
___
</details>
<details>
<summary>Nuke: Multiple format supports for ExtractReviewDataMov <a href="https://github.com/ynput/OpenPype/pull/5623">#5623</a></summary>
This PR would fix the bug of the plugin `ExtractReviewDataMov` not being able to support extensions other than `mov`. The plugin is also renamed to `ExtractReviewDataBakingStreams` as i provides multiple format supoort.
___
</details>
<details>
<summary>Bugfix: houdini switching context doesnt update variables <a href="https://github.com/ynput/OpenPype/pull/5651">#5651</a></summary>
Allows admins to have a list of vars (e.g. JOB) with (dynamic) values that will be updated on context changes, e.g. when switching to another asset or task.Using template keys is supported but formatting keys capitalization variants is not, e.g. {Asset} and {ASSET} won't workDisabling Update Houdini vars on context change feature will leave all Houdini vars unmanaged and thus no context update changes will occur.Also, this PR adds a new button in menu to update vars on demand.
___
</details>
<details>
<summary>Publisher: Fix report maker memory leak + optimize lookups using set <a href="https://github.com/ynput/OpenPype/pull/5667">#5667</a></summary>
Fixes a memory leak where resetting publisher does not clear the stored plugins for the Publish Report Maker.Also changes the stored plugins to a `set` to optimize the lookup speeds.
___
</details>
<details>
<summary>Add openpype_mongo command flag for testing. <a href="https://github.com/ynput/OpenPype/pull/5676">#5676</a></summary>
Instead of changing the environment, this command flag allows for changing the database.
___
</details>
<details>
<summary>Nuke: minor docstring and code tweaks for ExtractReviewMov <a href="https://github.com/ynput/OpenPype/pull/5695">#5695</a></summary>
Code and docstring tweaks on https://github.com/ynput/OpenPype/pull/5623
___
</details>
<details>
<summary>AYON: Small settings fixes <a href="https://github.com/ynput/OpenPype/pull/5699">#5699</a></summary>
Small changes/fixes related to AYON settings. All foundry apps variant `13-0` has label `13.0`. Key `"ExtractReviewIntermediates"` is not mandatory in settings.
___
</details>
<details>
<summary>Blender: Alembic Animation loader <a href="https://github.com/ynput/OpenPype/pull/5711">#5711</a></summary>
Implemented loading Alembic Animations in Blender.
___
</details>
### **🐛 Bug fixes**
<details>
<summary>Maya: Missing "data" field and enabling of audio <a href="https://github.com/ynput/OpenPype/pull/5618">#5618</a></summary>
When updating audio containers, the field "data" was missing and the audio node was not enabled on the timeline.
___
</details>
<details>
<summary>Maya: Bug in validate Plug-in Path Attribute <a href="https://github.com/ynput/OpenPype/pull/5687">#5687</a></summary>
Overwriting list with string is causing `TypeError: string indices must be integers` in subsequent iterations, crashing the validator plugin.
___
</details>
<details>
<summary>General: Avoid fallback if value is 0 for handle start/end <a href="https://github.com/ynput/OpenPype/pull/5652">#5652</a></summary>
There's a bug on the `pyblish_functions.get_time_data_from_instance_or_context` where if `handleStart` or `handleEnd` on the instance are set to value 0 it's falling back to grabbing the handles from the instance context. Instead, the logic should be that it only falls back to the `instance.context` if the key doesn't exist.This change was only affecting me on the `handleStart`/`handleEnd` and it's unlikely it could cause issues on `frameStart`, `frameEnd` or `fps` but regardless, the `get` logic is wrong.
___
</details>
<details>
<summary>Fusion: added missing env vars to Deadline submission <a href="https://github.com/ynput/OpenPype/pull/5659">#5659</a></summary>
Environment variables discerning type of job was missing. Without this injection of environment variables won't start.
___
</details>
<details>
<summary>Nuke: workfile version synchronization settings fixed <a href="https://github.com/ynput/OpenPype/pull/5662">#5662</a></summary>
Settings for synchronizing workfile version to published products is fixed.
___
</details>
<details>
<summary>AYON Workfiles Tool: Open workfile changes context <a href="https://github.com/ynput/OpenPype/pull/5671">#5671</a></summary>
Change context when workfile is opened.
___
</details>
<details>
<summary>Blender: Fix remove/update in new layout instance <a href="https://github.com/ynput/OpenPype/pull/5679">#5679</a></summary>
Fixes an error that occurs when removing or updating an asset in a new layout instance.
___
</details>
<details>
<summary>AYON Launcher tool: Fix refresh btn <a href="https://github.com/ynput/OpenPype/pull/5685">#5685</a></summary>
Refresh button does propagate refreshed content properly. Folders and tasks are cached for 60 seconds instead of 10 seconds. Auto-refresh in launcher will refresh only actions and related data which is project and project settings.
___
</details>
<details>
<summary>Deadline: handle all valid paths in RenderExecutable <a href="https://github.com/ynput/OpenPype/pull/5694">#5694</a></summary>
This commit enhances the path resolution mechanism in the RenderExecutable function of the Ayon plugin. Previously, the function only considered paths starting with a tilde (~), ignoring other valid paths listed in exe_list. This limitation led to an empty expanded_paths list when none of the paths in exe_list started with a tilde, causing the function to fail in finding the Ayon executable.With this fix, the RenderExecutable function now correctly processes and includes all valid paths from exe_list, improving its reliability and preventing unnecessary errors related to Ayon executable location.
___
</details>
<details>
<summary>AYON Launcher tool: Fix skip last workfile boolean <a href="https://github.com/ynput/OpenPype/pull/5700">#5700</a></summary>
Skip last workfile boolean works as expected.
___
</details>
<details>
<summary>Chore: Explore here action can work without task <a href="https://github.com/ynput/OpenPype/pull/5703">#5703</a></summary>
Explore here action does not crash when task is not selected, and change error message a little.
___
</details>
<details>
<summary>Testing: Inject mongo_url argument earlier <a href="https://github.com/ynput/OpenPype/pull/5706">#5706</a></summary>
Fix for https://github.com/ynput/OpenPype/pull/5676The Mongo url is used earlier in the execution.
___
</details>
<details>
<summary>Blender: Add support to auto-install PySide2 in blender 4 <a href="https://github.com/ynput/OpenPype/pull/5723">#5723</a></summary>
Change version regex to support blender 4 subfolder.
___
</details>
<details>
<summary>Fix: Hardcoded main site and wrongly copied workfile <a href="https://github.com/ynput/OpenPype/pull/5733">#5733</a></summary>
Fixing these two issues:
- Hardcoded main site -> Replaced by `anatomy.fill_root`.
- Workfiles can sometimes be copied while they shouldn't.
___
</details>
<details>
<summary>Bugfix: ServerDeleteOperation asset -> folder conversion typo <a href="https://github.com/ynput/OpenPype/pull/5735">#5735</a></summary>
Fix ServerDeleteOperation asset -> folder conversion typo
___
</details>
<details>
<summary>Nuke: loaders are filtering correctly <a href="https://github.com/ynput/OpenPype/pull/5739">#5739</a></summary>
Variable name for filtering by extensions were not correct - it suppose to be plural. It is fixed now and filtering is working as suppose to.
___
</details>
<details>
<summary>Nuke: failing multiple thumbnails integration <a href="https://github.com/ynput/OpenPype/pull/5741">#5741</a></summary>
This handles the situation when `ExtractReviewIntermediates` (previously `ExtractReviewDataMov`) has multiple outputs, including thumbnails that need to be integrated. Previously, integrating the thumbnail representation was causing an issue in the integration process. However, we have now resolved this issue by no longer integrating thumbnails as loadable representations.NOW default is that thumbnail representation are NOT integrated (eg. they will not show up in DB > couldn't be Loaded in Loader) and no `_thumb.jpg` will be left in `render` (most likely) publish folder.IF there would be need to override this behavior, please use `project_settings/global/publish/PreIntegrateThumbnails`
___
</details>
<details>
<summary>AYON Settings: Fix global overrides <a href="https://github.com/ynput/OpenPype/pull/5745">#5745</a></summary>
The `output` dictionary that gets passed into `ayon_settings._convert_global_project_settings` gets replaced when converting the settings for `ExtractOIIOTranscode`. This results in `global` not being in the output dictionary and thus the defaults being used and not the project overrides.
___
</details>
<details>
<summary>Chore: AYON query functions arguments <a href="https://github.com/ynput/OpenPype/pull/5752">#5752</a></summary>
Fixed how `archived` argument is handled in get subsets/assets function.
___
</details>
### **🔀 Refactored code**
<details>
<summary>Publisher: Refactor Report Maker plugin data storage to be a dict by plugin.id <a href="https://github.com/ynput/OpenPype/pull/5668">#5668</a></summary>
Refactor Report Maker plugin data storage to be a dict by `plugin.id`Also fixes `_current_plugin_data` type on `__init__`
___
</details>
<details>
<summary>Chore: Refactor Resolve into new style HostBase, IWorkfileHost, ILoadHost <a href="https://github.com/ynput/OpenPype/pull/5701">#5701</a></summary>
Refactor Resolve into new style HostBase, IWorkfileHost, ILoadHost
___
</details>
### **Merged pull requests**
<details>
<summary>Chore: Maya reduce get project settings calls <a href="https://github.com/ynput/OpenPype/pull/5669">#5669</a></summary>
Re-use system settings / project settings where we can instead of requerying.
___
</details>
<details>
<summary>Extended error message when getting subset name <a href="https://github.com/ynput/OpenPype/pull/5649">#5649</a></summary>
Each Creator is using `get_subset_name` functions which collects context data and fills configured template with placeholders.If any key is missing in the template, non descriptive error is thrown.This should provide more verbose message:
___
</details>
<details>
<summary>Tests: Remove checks for env var <a href="https://github.com/ynput/OpenPype/pull/5696">#5696</a></summary>
Env var will be filled in `env_var` fixture, here it is too early to check
___
</details>
## [3.17.1](https://github.com/ynput/OpenPype/tree/3.17.1)

View file

@ -75,9 +75,9 @@ def _get_subsets(
):
fields.add(key)
active = None
active = True
if archived:
active = False
active = None
for subset in con.get_products(
project_name,
@ -196,7 +196,7 @@ def get_assets(
active = True
if archived:
active = False
active = None
con = get_server_api_connection()
fields = folder_fields_v3_to_v4(fields, con)

View file

@ -460,36 +460,6 @@ def ls() -> Iterator:
yield parse_container(container)
def update_hierarchy(containers):
"""Hierarchical container support
This is the function to support Scene Inventory to draw hierarchical
view for containers.
We need both parent and children to visualize the graph.
"""
all_containers = set(ls()) # lookup set
for container in containers:
# Find parent
# FIXME (jasperge): re-evaluate this. How would it be possible
# to 'nest' assets? Collections can have several parents, for
# now assume it has only 1 parent
parent = [
coll for coll in bpy.data.collections if container in coll.children
]
for node in parent:
if node in all_containers:
container["parent"] = node
break
log.debug("Container: %s", container)
yield container
def publish():
"""Shorthand to publish from within host."""

View file

@ -165,7 +165,8 @@ class CreateSaver(NewCreator):
filepath = self.temp_rendering_path_template.format(
**formatting_data)
tool["Clip"] = os.path.normpath(filepath)
comp = get_current_comp()
tool["Clip"] = comp.ReverseMapPath(os.path.normpath(filepath))
# Rename tool
if tool.Name != subset:

View file

@ -161,7 +161,7 @@ class FusionLoadSequence(load.LoaderPlugin):
with comp_lock_and_undo_chunk(comp, "Create Loader"):
args = (-32768, -32768)
tool = comp.AddTool("Loader", *args)
tool["Clip"] = path
tool["Clip"] = comp.ReverseMapPath(path)
# Set global in point to start frame (if in version.data)
start = self._get_start(context["version"], tool)
@ -244,7 +244,7 @@ class FusionLoadSequence(load.LoaderPlugin):
"TimeCodeOffset",
),
):
tool["Clip"] = path
tool["Clip"] = comp.ReverseMapPath(path)
# Set the global in to the start frame of the sequence
global_in_changed = loader_shift(tool, start, relative=False)

View file

@ -145,9 +145,11 @@ class CollectFusionRender(
start = render_instance.frameStart - render_instance.handleStart
end = render_instance.frameEnd + render_instance.handleEnd
path = (
render_instance.tool["Clip"]
[render_instance.workfileComp.TIME_UNDEFINED]
comp = render_instance.workfileComp
path = comp.MapPath(
render_instance.tool["Clip"][
render_instance.workfileComp.TIME_UNDEFINED
]
)
output_dir = os.path.dirname(path)
render_instance.outputDir = output_dir

View file

@ -36,6 +36,7 @@ class ExtractPointCloud(publish.Extractor):
label = "Extract Point Cloud"
hosts = ["max"]
families = ["pointcloud"]
settings = []
def process(self, instance):
self.settings = self.get_setting(instance)

View file

@ -1,21 +1,24 @@
# -*- coding: utf-8 -*-
import pyblish.api
from openpype.pipeline import PublishValidationError
from pymxs import runtime as rt
class ValidateMaxContents(pyblish.api.InstancePlugin):
"""Validates Max contents.
class ValidateInstanceHasMembers(pyblish.api.InstancePlugin):
"""Validates Instance has members.
Check if MaxScene container includes any contents underneath.
Check if MaxScene containers includes any contents underneath.
"""
order = pyblish.api.ValidatorOrder
families = ["camera",
"model",
"maxScene",
"review"]
"review",
"pointcache",
"pointcloud",
"redshiftproxy"]
hosts = ["max"]
label = "Max Scene Contents"
label = "Container Contents"
def process(self, instance):
if not instance.data["members"]:

View file

@ -100,8 +100,8 @@ class ValidatePointCloud(pyblish.api.InstancePlugin):
selection_list = instance.data["members"]
project_setting = instance.data["project_setting"]
attr_settings = project_setting["max"]["PointCloud"]["attribute"]
project_settings = instance.context.data["project_settings"]
attr_settings = project_settings["max"]["PointCloud"]["attribute"]
for sel in selection_list:
obj = sel.baseobject
anim_names = rt.GetSubAnimNames(obj)

View file

@ -146,6 +146,10 @@ def suspended_refresh(suspend=True):
cmds.ogs(pause=True) is a toggle so we cant pass False.
"""
if IS_HEADLESS:
yield
return
original_state = cmds.ogs(query=True, pause=True)
try:
if suspend and not original_state:

View file

@ -0,0 +1,211 @@
from ayon_api import (
get_folder_by_name,
get_folder_by_path,
get_folders,
)
from maya import cmds # noqa: F401
from openpype import AYON_SERVER_ENABLED
from openpype.client import get_assets
from openpype.hosts.maya.api import plugin
from openpype.lib import BoolDef, EnumDef, TextDef
from openpype.pipeline import (
Creator,
get_current_asset_name,
get_current_project_name,
)
from openpype.pipeline.create import CreatorError
class CreateMultishotLayout(plugin.MayaCreator):
"""Create a multi-shot layout in the Maya scene.
This creator will create a Camera Sequencer in the Maya scene based on
the shots found under the specified folder. The shots will be added to
the sequencer in the order of their clipIn and clipOut values. For each
shot a Layout will be created.
"""
identifier = "io.openpype.creators.maya.multishotlayout"
label = "Multi-shot Layout"
family = "layout"
icon = "project-diagram"
def get_pre_create_attr_defs(self):
# Present artist with a list of parents of the current context
# to choose from. This will be used to get the shots under the
# selected folder to create the Camera Sequencer.
"""
Todo: `get_folder_by_name` should be switched to `get_folder_by_path`
once the fork to pure AYON is done.
Warning: this will not work for projects where the asset name
is not unique across the project until the switch mentioned
above is done.
"""
current_folder = get_folder_by_name(
project_name=get_current_project_name(),
folder_name=get_current_asset_name(),
)
current_path_parts = current_folder["path"].split("/")
# populate the list with parents of the current folder
# this will create menu items like:
# [
# {
# "value": "",
# "label": "project (shots directly under the project)"
# }, {
# "value": "shots/shot_01", "label": "shot_01 (current)"
# }, {
# "value": "shots", "label": "shots"
# }
# ]
# add the project as the first item
items_with_label = [
{
"label": f"{self.project_name} "
"(shots directly under the project)",
"value": ""
}
]
# go through the current folder path and add each part to the list,
# but mark the current folder.
for part_idx, part in enumerate(current_path_parts):
label = part
if label == current_folder["name"]:
label = f"{label} (current)"
value = "/".join(current_path_parts[:part_idx + 1])
items_with_label.append({"label": label, "value": value})
return [
EnumDef("shotParent",
default=current_folder["name"],
label="Shot Parent Folder",
items=items_with_label,
),
BoolDef("groupLoadedAssets",
label="Group Loaded Assets",
tooltip="Enable this when you want to publish group of "
"loaded asset",
default=False),
TextDef("taskName",
label="Associated Task Name",
tooltip=("Task name to be associated "
"with the created Layout"),
default="layout"),
]
def create(self, subset_name, instance_data, pre_create_data):
shots = list(
self.get_related_shots(folder_path=pre_create_data["shotParent"])
)
if not shots:
# There are no shot folders under the specified folder.
# We are raising an error here but in the future we might
# want to create a new shot folders by publishing the layouts
# and shot defined in the sequencer. Sort of editorial publish
# in side of Maya.
raise CreatorError((
"No shots found under the specified "
f"folder: {pre_create_data['shotParent']}."))
# Get layout creator
layout_creator_id = "io.openpype.creators.maya.layout"
layout_creator: Creator = self.create_context.creators.get(
layout_creator_id)
if not layout_creator:
raise CreatorError(
f"Creator {layout_creator_id} not found.")
# Get OpenPype style asset documents for the shots
op_asset_docs = get_assets(
self.project_name, [s["id"] for s in shots])
asset_docs_by_id = {doc["_id"]: doc for doc in op_asset_docs}
for shot in shots:
# we are setting shot name to be displayed in the sequencer to
# `shot name (shot label)` if the label is set, otherwise just
# `shot name`. So far, labels are used only when the name is set
# with characters that are not allowed in the shot name.
if not shot["active"]:
continue
# get task for shot
asset_doc = asset_docs_by_id[shot["id"]]
tasks = asset_doc.get("data").get("tasks").keys()
layout_task = None
if pre_create_data["taskName"] in tasks:
layout_task = pre_create_data["taskName"]
shot_name = f"{shot['name']}%s" % (
f" ({shot['label']})" if shot["label"] else "")
cmds.shot(sequenceStartTime=shot["attrib"]["clipIn"],
sequenceEndTime=shot["attrib"]["clipOut"],
shotName=shot_name)
# Create layout instance by the layout creator
instance_data = {
"asset": shot["name"],
"variant": layout_creator.get_default_variant()
}
if layout_task:
instance_data["task"] = layout_task
layout_creator.create(
subset_name=layout_creator.get_subset_name(
layout_creator.get_default_variant(),
self.create_context.get_current_task_name(),
asset_doc,
self.project_name),
instance_data=instance_data,
pre_create_data={
"groupLoadedAssets": pre_create_data["groupLoadedAssets"]
}
)
def get_related_shots(self, folder_path: str):
"""Get all shots related to the current asset.
Get all folders of type Shot under specified folder.
Args:
folder_path (str): Path of the folder.
Returns:
list: List of dicts with folder data.
"""
# if folder_path is None, project is selected as a root
# and its name is used as a parent id
parent_id = self.project_name
if folder_path:
current_folder = get_folder_by_path(
project_name=self.project_name,
folder_path=folder_path,
)
parent_id = current_folder["id"]
# get all child folders of the current one
return get_folders(
project_name=self.project_name,
parent_ids=[parent_id],
fields=[
"attrib.clipIn", "attrib.clipOut",
"attrib.frameStart", "attrib.frameEnd",
"name", "label", "path", "folderType", "id"
]
)
# blast this creator if Ayon server is not enabled
if not AYON_SERVER_ENABLED:
del CreateMultishotLayout

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
"""Maya look extractor."""
import sys
from abc import ABCMeta, abstractmethod
from collections import OrderedDict
import contextlib
@ -176,6 +177,24 @@ class MakeRSTexBin(TextureProcessor):
source
]
# if color management is enabled we pass color space information
if color_management["enabled"]:
config_path = color_management["config"]
if not os.path.exists(config_path):
raise RuntimeError("OCIO config not found at: "
"{}".format(config_path))
if not os.getenv("OCIO"):
self.log.debug(
"OCIO environment variable not set."
"Setting it with OCIO config from Maya."
)
os.environ["OCIO"] = config_path
self.log.debug("converting colorspace {0} to redshift render "
"colorspace".format(colorspace))
subprocess_args.extend(["-cs", colorspace])
hash_args = ["rstex"]
texture_hash = source_hash(source, *hash_args)
@ -186,11 +205,11 @@ class MakeRSTexBin(TextureProcessor):
self.log.debug(" ".join(subprocess_args))
try:
run_subprocess(subprocess_args)
run_subprocess(subprocess_args, logger=self.log)
except Exception:
self.log.error("Texture .rstexbin conversion failed",
exc_info=True)
raise
six.reraise(*sys.exc_info())
return TextureResult(
path=destination,
@ -472,7 +491,7 @@ class ExtractLook(publish.Extractor):
"rstex": MakeRSTexBin
}.items():
if instance.data.get(key, False):
processor = Processor()
processor = Processor(log=self.log)
processor.apply_settings(context.data["system_settings"],
context.data["project_settings"])
processors.append(processor)

View file

@ -50,6 +50,11 @@ from .utils import (
get_colorspace_list
)
from .actions import (
SelectInvalidAction,
SelectInstanceNodeAction
)
__all__ = (
"file_extensions",
"has_unsaved_changes",
@ -92,5 +97,8 @@ __all__ = (
"create_write_node",
"colorspace_exists_on_node",
"get_colorspace_list"
"get_colorspace_list",
"SelectInvalidAction",
"SelectInstanceNodeAction"
)

View file

@ -20,33 +20,58 @@ class SelectInvalidAction(pyblish.api.Action):
def process(self, context, plugin):
try:
import nuke
except ImportError:
raise ImportError("Current host is not Nuke")
errored_instances = get_errored_instances_from_context(context,
plugin=plugin)
# Get the invalid nodes for the plug-ins
self.log.info("Finding invalid nodes..")
invalid = list()
invalid = set()
for instance in errored_instances:
invalid_nodes = plugin.get_invalid(instance)
if invalid_nodes:
if isinstance(invalid_nodes, (list, tuple)):
invalid.append(invalid_nodes[0])
invalid.update(invalid_nodes)
else:
self.log.warning("Plug-in returned to be invalid, "
"but has no selectable nodes.")
# Ensure unique (process each node only once)
invalid = list(set(invalid))
if invalid:
self.log.info("Selecting invalid nodes: {}".format(invalid))
reset_selection()
select_nodes(invalid)
else:
self.log.info("No invalid nodes found.")
class SelectInstanceNodeAction(pyblish.api.Action):
"""Select instance node for failed plugin."""
label = "Select instance node"
on = "failed" # This action is only available on a failed plug-in
icon = "mdi.cursor-default-click"
def process(self, context, plugin):
# Get the errored instances for the plug-in
errored_instances = get_errored_instances_from_context(
context, plugin)
# Get the invalid nodes for the plug-ins
self.log.info("Finding instance nodes..")
nodes = set()
for instance in errored_instances:
instance_node = instance.data.get("transientData", {}).get("node")
if not instance_node:
raise RuntimeError(
"No transientData['node'] found on instance: {}".format(
instance
)
)
nodes.add(instance_node)
if nodes:
self.log.info("Selecting instance nodes: {}".format(nodes))
reset_selection()
select_nodes(nodes)
else:
self.log.info("No instance nodes found.")

View file

@ -48,20 +48,15 @@ from openpype.pipeline import (
get_current_asset_name,
)
from openpype.pipeline.context_tools import (
get_current_project_asset,
get_custom_workfile_template_from_session
)
from openpype.pipeline.colorspace import (
get_imageio_config
)
from openpype.pipeline.colorspace import get_imageio_config
from openpype.pipeline.workfile import BuildWorkfile
from . import gizmo_menu
from .constants import ASSIST
from .workio import (
save_file,
open_file
)
from .workio import save_file
from .utils import get_node_outputs
log = Logger.get_logger(__name__)
@ -2802,16 +2797,28 @@ def find_free_space_to_paste_nodes(
@contextlib.contextmanager
def maintained_selection():
def maintained_selection(exclude_nodes=None):
"""Maintain selection during context
Maintain selection during context and unselect
all nodes after context is done.
Arguments:
exclude_nodes (list[nuke.Node]): list of nodes to be unselected
before context is done
Example:
>>> with maintained_selection():
... node["selected"].setValue(True)
>>> print(node["selected"].value())
False
"""
if exclude_nodes:
for node in exclude_nodes:
node["selected"].setValue(False)
previous_selection = nuke.selectedNodes()
try:
yield
finally:
@ -2823,6 +2830,51 @@ def maintained_selection():
select_nodes(previous_selection)
@contextlib.contextmanager
def swap_node_with_dependency(old_node, new_node):
""" Swap node with dependency
Swap node with dependency and reconnect all inputs and outputs.
It removes old node.
Arguments:
old_node (nuke.Node): node to be replaced
new_node (nuke.Node): node to replace with
Example:
>>> old_node_name = old_node["name"].value()
>>> print(old_node_name)
old_node_name_01
>>> with swap_node_with_dependency(old_node, new_node) as node_name:
... new_node["name"].setValue(node_name)
>>> print(new_node["name"].value())
old_node_name_01
"""
# preserve position
xpos, ypos = old_node.xpos(), old_node.ypos()
# preserve selection after all is done
outputs = get_node_outputs(old_node)
inputs = old_node.dependencies()
node_name = old_node["name"].value()
try:
nuke.delete(old_node)
yield node_name
finally:
# Reconnect inputs
for i, node in enumerate(inputs):
new_node.setInput(i, node)
# Reconnect outputs
if outputs:
for n, pipes in outputs.items():
for i in pipes:
n.setInput(i, new_node)
# return to original position
new_node.setXYpos(xpos, ypos)
def reset_selection():
"""Deselect all selected nodes"""
for node in nuke.selectedNodes():
@ -2833,9 +2885,10 @@ def select_nodes(nodes):
"""Selects all inputted nodes
Arguments:
nodes (list): nuke nodes to be selected
nodes (Union[list, tuple, set]): nuke nodes to be selected
"""
assert isinstance(nodes, (list, tuple)), "nodes has to be list or tuple"
assert isinstance(nodes, (list, tuple, set)), \
"nodes has to be list, tuple or set"
for node in nodes:
node["selected"].setValue(True)
@ -2919,13 +2972,13 @@ def process_workfile_builder():
"workfile_builder", {})
# get settings
createfv_on = workfile_builder.get("create_first_version") or None
create_fv_on = workfile_builder.get("create_first_version") or None
builder_on = workfile_builder.get("builder_on_start") or None
last_workfile_path = os.environ.get("AVALON_LAST_WORKFILE")
# generate first version in file not existing and feature is enabled
if createfv_on and not os.path.exists(last_workfile_path):
if create_fv_on and not os.path.exists(last_workfile_path):
# get custom template path if any
custom_template_path = get_custom_workfile_template_from_session(
project_settings=project_settings

View file

@ -12,7 +12,8 @@ from openpype.pipeline import (
from openpype.hosts.nuke.api.lib import (
maintained_selection,
get_avalon_knob_data,
set_avalon_knob_data
set_avalon_knob_data,
swap_node_with_dependency,
)
from openpype.hosts.nuke.api import (
containerise,
@ -26,7 +27,7 @@ class LoadGizmo(load.LoaderPlugin):
families = ["gizmo"]
representations = ["*"]
extensions = {"gizmo"}
extensions = {"nk"}
label = "Load Gizmo"
order = 0
@ -45,7 +46,7 @@ class LoadGizmo(load.LoaderPlugin):
data (dict): compulsory attribute > not used
Returns:
nuke node: containerised nuke node object
nuke node: containerized nuke node object
"""
# get main variables
@ -83,12 +84,12 @@ class LoadGizmo(load.LoaderPlugin):
# add group from nk
nuke.nodePaste(file)
GN = nuke.selectedNode()
group_node = nuke.selectedNode()
GN["name"].setValue(object_name)
group_node["name"].setValue(object_name)
return containerise(
node=GN,
node=group_node,
name=name,
namespace=namespace,
context=context,
@ -110,7 +111,7 @@ class LoadGizmo(load.LoaderPlugin):
version_doc = get_version_by_id(project_name, representation["parent"])
# get corresponding node
GN = nuke.toNode(container['objectName'])
group_node = nuke.toNode(container['objectName'])
file = get_representation_path(representation).replace("\\", "/")
name = container['name']
@ -135,22 +136,24 @@ class LoadGizmo(load.LoaderPlugin):
for k in add_keys:
data_imprint.update({k: version_data[k]})
# capture pipeline metadata
avalon_data = get_avalon_knob_data(group_node)
# adding nodes to node graph
# just in case we are in group lets jump out of it
nuke.endGroup()
with maintained_selection():
xpos = GN.xpos()
ypos = GN.ypos()
avalon_data = get_avalon_knob_data(GN)
nuke.delete(GN)
# add group from nk
with maintained_selection([group_node]):
# insert nuke script to the script
nuke.nodePaste(file)
GN = nuke.selectedNode()
set_avalon_knob_data(GN, avalon_data)
GN.setXYpos(xpos, ypos)
GN["name"].setValue(object_name)
# convert imported to selected node
new_group_node = nuke.selectedNode()
# swap nodes with maintained connections
with swap_node_with_dependency(
group_node, new_group_node) as node_name:
new_group_node["name"].setValue(node_name)
# set updated pipeline metadata
set_avalon_knob_data(new_group_node, avalon_data)
last_version_doc = get_last_version_by_subset_id(
project_name, version_doc["parent"], fields=["_id"]
@ -161,11 +164,12 @@ class LoadGizmo(load.LoaderPlugin):
color_value = self.node_color
else:
color_value = "0xd88467ff"
GN["tile_color"].setValue(int(color_value, 16))
new_group_node["tile_color"].setValue(int(color_value, 16))
self.log.info("updated to version: {}".format(version_doc.get("name")))
return update_container(GN, data_imprint)
return update_container(new_group_node, data_imprint)
def switch(self, container, representation):
self.update(container, representation)

View file

@ -14,7 +14,8 @@ from openpype.hosts.nuke.api.lib import (
maintained_selection,
create_backdrop,
get_avalon_knob_data,
set_avalon_knob_data
set_avalon_knob_data,
swap_node_with_dependency,
)
from openpype.hosts.nuke.api import (
containerise,
@ -28,7 +29,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin):
families = ["gizmo"]
representations = ["*"]
extensions = {"gizmo"}
extensions = {"nk"}
label = "Load Gizmo - Input Process"
order = 0
@ -47,7 +48,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin):
data (dict): compulsory attribute > not used
Returns:
nuke node: containerised nuke node object
nuke node: containerized nuke node object
"""
# get main variables
@ -85,17 +86,17 @@ class LoadGizmoInputProcess(load.LoaderPlugin):
# add group from nk
nuke.nodePaste(file)
GN = nuke.selectedNode()
group_node = nuke.selectedNode()
GN["name"].setValue(object_name)
group_node["name"].setValue(object_name)
# try to place it under Viewer1
if not self.connect_active_viewer(GN):
nuke.delete(GN)
if not self.connect_active_viewer(group_node):
nuke.delete(group_node)
return
return containerise(
node=GN,
node=group_node,
name=name,
namespace=namespace,
context=context,
@ -117,7 +118,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin):
version_doc = get_version_by_id(project_name, representation["parent"])
# get corresponding node
GN = nuke.toNode(container['objectName'])
group_node = nuke.toNode(container['objectName'])
file = get_representation_path(representation).replace("\\", "/")
name = container['name']
@ -142,22 +143,24 @@ class LoadGizmoInputProcess(load.LoaderPlugin):
for k in add_keys:
data_imprint.update({k: version_data[k]})
# capture pipeline metadata
avalon_data = get_avalon_knob_data(group_node)
# adding nodes to node graph
# just in case we are in group lets jump out of it
nuke.endGroup()
with maintained_selection():
xpos = GN.xpos()
ypos = GN.ypos()
avalon_data = get_avalon_knob_data(GN)
nuke.delete(GN)
# add group from nk
with maintained_selection([group_node]):
# insert nuke script to the script
nuke.nodePaste(file)
GN = nuke.selectedNode()
set_avalon_knob_data(GN, avalon_data)
GN.setXYpos(xpos, ypos)
GN["name"].setValue(object_name)
# convert imported to selected node
new_group_node = nuke.selectedNode()
# swap nodes with maintained connections
with swap_node_with_dependency(
group_node, new_group_node) as node_name:
new_group_node["name"].setValue(node_name)
# set updated pipeline metadata
set_avalon_knob_data(new_group_node, avalon_data)
last_version_doc = get_last_version_by_subset_id(
project_name, version_doc["parent"], fields=["_id"]
@ -168,11 +171,11 @@ class LoadGizmoInputProcess(load.LoaderPlugin):
color_value = self.node_color
else:
color_value = "0xd88467ff"
GN["tile_color"].setValue(int(color_value, 16))
new_group_node["tile_color"].setValue(int(color_value, 16))
self.log.info("updated to version: {}".format(version_doc.get("name")))
return update_container(GN, data_imprint)
return update_container(new_group_node, data_imprint)
def connect_active_viewer(self, group_node):
"""

View file

@ -57,4 +57,4 @@ class CollectBackdrops(pyblish.api.InstancePlugin):
if version:
instance.data['version'] = version
self.log.info("Backdrop instance collected: `{}`".format(instance))
self.log.debug("Backdrop instance collected: `{}`".format(instance))

View file

@ -64,4 +64,4 @@ class CollectContextData(pyblish.api.ContextPlugin):
context.data["scriptData"] = script_data
context.data.update(script_data)
self.log.info('Context from Nuke script collected')
self.log.debug('Context from Nuke script collected')

View file

@ -43,4 +43,4 @@ class CollectGizmo(pyblish.api.InstancePlugin):
"frameStart": first_frame,
"frameEnd": last_frame
})
self.log.info("Gizmo instance collected: `{}`".format(instance))
self.log.debug("Gizmo instance collected: `{}`".format(instance))

View file

@ -43,4 +43,4 @@ class CollectModel(pyblish.api.InstancePlugin):
"frameStart": first_frame,
"frameEnd": last_frame
})
self.log.info("Model instance collected: `{}`".format(instance))
self.log.debug("Model instance collected: `{}`".format(instance))

View file

@ -39,7 +39,7 @@ class CollectSlate(pyblish.api.InstancePlugin):
instance.data["slateNode"] = slate_node
instance.data["slate"] = True
instance.data["families"].append("slate")
self.log.info(
self.log.debug(
"Slate node is in node graph: `{}`".format(slate.name()))
self.log.debug(
"__ instance.data: `{}`".format(instance.data))

View file

@ -37,4 +37,6 @@ class CollectWorkfile(pyblish.api.InstancePlugin):
# adding basic script data
instance.data.update(script_data)
self.log.info("Collect script version")
self.log.debug(
"Collected current script version: {}".format(current_file)
)

View file

@ -56,8 +56,6 @@ class ExtractBackdropNode(publish.Extractor):
# connect output node
for n, output in connections_out.items():
opn = nuke.createNode("Output")
self.log.info(n.name())
self.log.info(output.name())
output.setInput(
next((i for i, d in enumerate(output.dependencies())
if d.name() in n.name()), 0), opn)
@ -102,5 +100,5 @@ class ExtractBackdropNode(publish.Extractor):
}
instance.data["representations"].append(representation)
self.log.info("Extracted instance '{}' to: {}".format(
self.log.debug("Extracted instance '{}' to: {}".format(
instance.name, path))

View file

@ -36,11 +36,8 @@ class ExtractCamera(publish.Extractor):
step = 1
output_range = str(nuke.FrameRange(first_frame, last_frame, step))
self.log.info("instance.data: `{}`".format(
pformat(instance.data)))
rm_nodes = []
self.log.info("Crating additional nodes")
self.log.debug("Creating additional nodes for 3D Camera Extractor")
subset = instance.data["subset"]
staging_dir = self.staging_dir(instance)
@ -84,8 +81,6 @@ class ExtractCamera(publish.Extractor):
for n in rm_nodes:
nuke.delete(n)
self.log.info(file_path)
# create representation data
if "representations" not in instance.data:
instance.data["representations"] = []
@ -112,7 +107,7 @@ class ExtractCamera(publish.Extractor):
"frameEndHandle": last_frame,
})
self.log.info("Extracted instance '{0}' to: {1}".format(
self.log.debug("Extracted instance '{0}' to: {1}".format(
instance.name, file_path))

View file

@ -85,8 +85,5 @@ class ExtractGizmo(publish.Extractor):
}
instance.data["representations"].append(representation)
self.log.info("Extracted instance '{}' to: {}".format(
self.log.debug("Extracted instance '{}' to: {}".format(
instance.name, path))
self.log.info("Data {}".format(
instance.data))

View file

@ -33,13 +33,13 @@ class ExtractModel(publish.Extractor):
first_frame = int(nuke.root()["first_frame"].getValue())
last_frame = int(nuke.root()["last_frame"].getValue())
self.log.info("instance.data: `{}`".format(
self.log.debug("instance.data: `{}`".format(
pformat(instance.data)))
rm_nodes = []
model_node = instance.data["transientData"]["node"]
self.log.info("Crating additional nodes")
self.log.debug("Creating additional nodes for Extract Model")
subset = instance.data["subset"]
staging_dir = self.staging_dir(instance)
@ -76,7 +76,7 @@ class ExtractModel(publish.Extractor):
for n in rm_nodes:
nuke.delete(n)
self.log.info(file_path)
self.log.debug("Filepath: {}".format(file_path))
# create representation data
if "representations" not in instance.data:
@ -104,5 +104,5 @@ class ExtractModel(publish.Extractor):
"frameEndHandle": last_frame,
})
self.log.info("Extracted instance '{0}' to: {1}".format(
self.log.debug("Extracted instance '{0}' to: {1}".format(
instance.name, file_path))

View file

@ -27,7 +27,7 @@ class CreateOutputNode(pyblish.api.ContextPlugin):
if active_node:
active_node = active_node.pop()
self.log.info(active_node)
self.log.debug("Active node: {}".format(active_node))
active_node['selected'].setValue(True)
# select only instance render node

View file

@ -119,7 +119,7 @@ class NukeRenderLocal(publish.Extractor,
instance.data["representations"].append(repre)
self.log.info("Extracted instance '{0}' to: {1}".format(
self.log.debug("Extracted instance '{0}' to: {1}".format(
instance.name,
out_dir
))
@ -143,7 +143,7 @@ class NukeRenderLocal(publish.Extractor,
instance.data["families"] = families
collections, remainder = clique.assemble(filenames)
self.log.info('collections: {}'.format(str(collections)))
self.log.debug('collections: {}'.format(str(collections)))
if collections:
collection = collections[0]

View file

@ -20,7 +20,7 @@ class ExtractReviewDataLut(publish.Extractor):
hosts = ["nuke"]
def process(self, instance):
self.log.info("Creating staging dir...")
self.log.debug("Creating staging dir...")
if "representations" in instance.data:
staging_dir = instance.data[
"representations"][0]["stagingDir"].replace("\\", "/")
@ -33,7 +33,7 @@ class ExtractReviewDataLut(publish.Extractor):
staging_dir = os.path.normpath(os.path.dirname(render_path))
instance.data["stagingDir"] = staging_dir
self.log.info(
self.log.debug(
"StagingDir `{0}`...".format(instance.data["stagingDir"]))
# generate data

View file

@ -52,7 +52,7 @@ class ExtractReviewIntermediates(publish.Extractor):
task_type = instance.context.data["taskType"]
subset = instance.data["subset"]
self.log.info("Creating staging dir...")
self.log.debug("Creating staging dir...")
if "representations" not in instance.data:
instance.data["representations"] = []
@ -62,10 +62,10 @@ class ExtractReviewIntermediates(publish.Extractor):
instance.data["stagingDir"] = staging_dir
self.log.info(
self.log.debug(
"StagingDir `{0}`...".format(instance.data["stagingDir"]))
self.log.info(self.outputs)
self.log.debug("Outputs: {}".format(self.outputs))
# generate data
with maintained_selection():
@ -104,9 +104,10 @@ class ExtractReviewIntermediates(publish.Extractor):
re.search(s, subset) for s in f_subsets):
continue
self.log.info(
self.log.debug(
"Baking output `{}` with settings: {}".format(
o_name, o_data))
o_name, o_data)
)
# check if settings have more then one preset
# so we dont need to add outputName to representation
@ -155,10 +156,10 @@ class ExtractReviewIntermediates(publish.Extractor):
instance.data["useSequenceForReview"] = False
else:
instance.data["families"].remove("review")
self.log.info((
self.log.debug(
"Removing `review` from families. "
"Not available baking profile."
))
)
self.log.debug(instance.data["families"])
self.log.debug(

View file

@ -3,13 +3,12 @@ import pyblish.api
class ExtractScriptSave(pyblish.api.Extractor):
"""
"""
"""Save current Nuke workfile script"""
label = 'Script Save'
order = pyblish.api.Extractor.order - 0.1
hosts = ['nuke']
def process(self, instance):
self.log.info('saving script')
self.log.debug('Saving current script')
nuke.scriptSave()

View file

@ -48,7 +48,7 @@ class ExtractSlateFrame(publish.Extractor):
if instance.data.get("bakePresets"):
for o_name, o_data in instance.data["bakePresets"].items():
self.log.info("_ o_name: {}, o_data: {}".format(
self.log.debug("_ o_name: {}, o_data: {}".format(
o_name, pformat(o_data)))
self.render_slate(
instance,
@ -65,14 +65,14 @@ class ExtractSlateFrame(publish.Extractor):
def _create_staging_dir(self, instance):
self.log.info("Creating staging dir...")
self.log.debug("Creating staging dir...")
staging_dir = os.path.normpath(
os.path.dirname(instance.data["path"]))
instance.data["stagingDir"] = staging_dir
self.log.info(
self.log.debug(
"StagingDir `{0}`...".format(instance.data["stagingDir"]))
def _check_frames_exists(self, instance):
@ -275,10 +275,10 @@ class ExtractSlateFrame(publish.Extractor):
break
if not matching_repre:
self.log.info((
"Matching reresentaion was not found."
self.log.info(
"Matching reresentation was not found."
" Representation files were not filled with slate."
))
)
return
# Add frame to matching representation files
@ -345,7 +345,7 @@ class ExtractSlateFrame(publish.Extractor):
try:
node[key].setValue(value)
self.log.info("Change key \"{}\" to value \"{}\"".format(
self.log.debug("Change key \"{}\" to value \"{}\"".format(
key, value
))
except NameError:

View file

@ -69,7 +69,7 @@ class ExtractThumbnail(publish.Extractor):
"bake_viewer_input_process"]
node = instance.data["transientData"]["node"] # group node
self.log.info("Creating staging dir...")
self.log.debug("Creating staging dir...")
if "representations" not in instance.data:
instance.data["representations"] = []
@ -79,7 +79,7 @@ class ExtractThumbnail(publish.Extractor):
instance.data["stagingDir"] = staging_dir
self.log.info(
self.log.debug(
"StagingDir `{0}`...".format(instance.data["stagingDir"]))
temporary_nodes = []

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Shot/Asset name</title>
<description>
## Publishing to a different asset context
There are publish instances present which are publishing into a different asset than your current context.
Usually this is not what you want but there can be cases where you might want to publish into another asset/shot or task.
If that's the case you can disable the validation on the instance to ignore it.
The wrong node's name is: \`{node_name}\`
### Correct context keys and values:
\`{correct_values}\`
### Wrong keys and values:
\`{wrong_values}\`.
## How to repair?
1. Use \"Repair\" button.
2. Hit Reload button on the publisher.
</description>
</error>
</root>

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Shot/Asset name</title>
<description>
## Invalid Shot/Asset name in subset
Following Node with name `{node_name}`:
Is in context of `{correct_name}` but Node _asset_ knob is set as `{wrong_name}`.
### How to repair?
1. Either use Repair or Select button.
2. If you chose Select then rename asset knob to correct name.
3. Hit Reload button on the publisher.
</description>
</error>
</root>

View file

@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
"""Validate if instance asset is the same as context asset."""
from __future__ import absolute_import
import pyblish.api
from openpype.pipeline.publish import (
RepairAction,
ValidateContentsOrder,
PublishXmlValidationError,
OptionalPyblishPluginMixin
)
from openpype.hosts.nuke.api import SelectInstanceNodeAction
class ValidateCorrectAssetContext(
pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin
):
"""Validator to check if instance asset context match context asset.
When working in per-shot style you always publish data in context of
current asset (shot). This validator checks if this is so. It is optional
so it can be disabled when needed.
Checking `asset` and `task` keys.
"""
order = ValidateContentsOrder
label = "Validate asset context"
hosts = ["nuke"]
actions = [
RepairAction,
SelectInstanceNodeAction
]
optional = True
@classmethod
def apply_settings(cls, project_settings):
"""Apply deprecated settings from project settings.
"""
nuke_publish = project_settings["nuke"]["publish"]
if "ValidateCorrectAssetName" in nuke_publish:
settings = nuke_publish["ValidateCorrectAssetName"]
else:
settings = nuke_publish["ValidateCorrectAssetContext"]
cls.enabled = settings["enabled"]
cls.optional = settings["optional"]
cls.active = settings["active"]
def process(self, instance):
if not self.is_active(instance.data):
return
invalid_keys = self.get_invalid(instance)
if not invalid_keys:
return
message_values = {
"node_name": instance.data["transientData"]["node"].name(),
"correct_values": ", ".join([
"{} > {}".format(_key, instance.context.data[_key])
for _key in invalid_keys
]),
"wrong_values": ", ".join([
"{} > {}".format(_key, instance.data.get(_key))
for _key in invalid_keys
])
}
msg = (
"Instance `{node_name}` has wrong context keys:\n"
"Correct: `{correct_values}` | Wrong: `{wrong_values}`").format(
**message_values)
self.log.debug(msg)
raise PublishXmlValidationError(
self, msg, formatting_data=message_values
)
@classmethod
def get_invalid(cls, instance):
"""Get invalid keys from instance data and context data."""
invalid_keys = []
testing_keys = ["asset", "task"]
for _key in testing_keys:
if _key not in instance.data:
invalid_keys.append(_key)
continue
if instance.data[_key] != instance.context.data[_key]:
invalid_keys.append(_key)
return invalid_keys
@classmethod
def repair(cls, instance):
"""Repair instance data with context data."""
invalid_keys = cls.get_invalid(instance)
create_context = instance.context.data["create_context"]
instance_id = instance.data.get("instance_id")
created_instance = create_context.get_instance_by_id(
instance_id
)
for _key in invalid_keys:
created_instance[_key] = instance.context.data[_key]
create_context.save_changes()

View file

@ -1,138 +0,0 @@
# -*- coding: utf-8 -*-
"""Validate if instance asset is the same as context asset."""
from __future__ import absolute_import
import pyblish.api
import openpype.hosts.nuke.api.lib as nlib
from openpype.pipeline.publish import (
ValidateContentsOrder,
PublishXmlValidationError,
OptionalPyblishPluginMixin
)
class SelectInvalidInstances(pyblish.api.Action):
"""Select invalid instances in Outliner."""
label = "Select"
icon = "briefcase"
on = "failed"
def process(self, context, plugin):
"""Process invalid validators and select invalid instances."""
# Get the errored instances
failed = []
for result in context.data["results"]:
if (
result["error"] is None
or result["instance"] is None
or result["instance"] in failed
or result["plugin"] != plugin
):
continue
failed.append(result["instance"])
# Apply pyblish.logic to get the instances for the plug-in
instances = pyblish.api.instances_by_plugin(failed, plugin)
if instances:
self.deselect()
self.log.info(
"Selecting invalid nodes: %s" % ", ".join(
[str(x) for x in instances]
)
)
self.select(instances)
else:
self.log.info("No invalid nodes found.")
self.deselect()
def select(self, instances):
for inst in instances:
if inst.data.get("transientData", {}).get("node"):
select_node = inst.data["transientData"]["node"]
select_node["selected"].setValue(True)
def deselect(self):
nlib.reset_selection()
class RepairSelectInvalidInstances(pyblish.api.Action):
"""Repair the instance asset."""
label = "Repair"
icon = "wrench"
on = "failed"
def process(self, context, plugin):
# Get the errored instances
failed = []
for result in context.data["results"]:
if (
result["error"] is None
or result["instance"] is None
or result["instance"] in failed
or result["plugin"] != plugin
):
continue
failed.append(result["instance"])
# Apply pyblish.logic to get the instances for the plug-in
instances = pyblish.api.instances_by_plugin(failed, plugin)
self.log.debug(instances)
context_asset = context.data["assetEntity"]["name"]
for instance in instances:
node = instance.data["transientData"]["node"]
node_data = nlib.get_node_data(node, nlib.INSTANCE_DATA_KNOB)
node_data["asset"] = context_asset
nlib.set_node_data(node, nlib.INSTANCE_DATA_KNOB, node_data)
class ValidateCorrectAssetName(
pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin
):
"""Validator to check if instance asset match context asset.
When working in per-shot style you always publish data in context of
current asset (shot). This validator checks if this is so. It is optional
so it can be disabled when needed.
Action on this validator will select invalid instances in Outliner.
"""
order = ValidateContentsOrder
label = "Validate correct asset name"
hosts = ["nuke"]
actions = [
SelectInvalidInstances,
RepairSelectInvalidInstances
]
optional = True
def process(self, instance):
if not self.is_active(instance.data):
return
asset = instance.data.get("asset")
context_asset = instance.context.data["assetEntity"]["name"]
node = instance.data["transientData"]["node"]
msg = (
"Instance `{}` has wrong shot/asset name:\n"
"Correct: `{}` | Wrong: `{}`").format(
instance.name, asset, context_asset)
self.log.debug(msg)
if asset != context_asset:
raise PublishXmlValidationError(
self, msg, formatting_data={
"node_name": node.name(),
"wrong_name": asset,
"correct_name": context_asset
}
)

View file

@ -43,8 +43,8 @@ class SelectCenterInNodeGraph(pyblish.api.Action):
all_xC.append(xC)
all_yC.append(yC)
self.log.info("all_xC: `{}`".format(all_xC))
self.log.info("all_yC: `{}`".format(all_yC))
self.log.debug("all_xC: `{}`".format(all_xC))
self.log.debug("all_yC: `{}`".format(all_yC))
# zoom to nodes in node graph
nuke.zoom(2, [min(all_xC), min(all_yC)])

View file

@ -23,7 +23,7 @@ class ValidateOutputResolution(
order = pyblish.api.ValidatorOrder
optional = True
families = ["render"]
label = "Write resolution"
label = "Validate Write resolution"
hosts = ["nuke"]
actions = [RepairAction]
@ -104,9 +104,9 @@ class ValidateOutputResolution(
_rfn["resize"].setValue(0)
_rfn["black_outside"].setValue(1)
cls.log.info("I am adding reformat node")
cls.log.info("Adding reformat node")
if cls.resolution_msg == invalid:
reformat = cls.get_reformat(instance)
reformat["format"].setValue(nuke.root()["format"].value())
cls.log.info("I am fixing reformat to root.format")
cls.log.info("Fixing reformat to root.format")

View file

@ -76,8 +76,8 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin):
return
collections, remainder = clique.assemble(repre["files"])
self.log.info("collections: {}".format(str(collections)))
self.log.info("remainder: {}".format(str(remainder)))
self.log.debug("collections: {}".format(str(collections)))
self.log.debug("remainder: {}".format(str(remainder)))
collection = collections[0]
@ -103,15 +103,15 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin):
coll_start = min(collection.indexes)
coll_end = max(collection.indexes)
self.log.info("frame_length: {}".format(frame_length))
self.log.info("collected_frames_len: {}".format(
self.log.debug("frame_length: {}".format(frame_length))
self.log.debug("collected_frames_len: {}".format(
collected_frames_len))
self.log.info("f_start_h-f_end_h: {}-{}".format(
self.log.debug("f_start_h-f_end_h: {}-{}".format(
f_start_h, f_end_h))
self.log.info(
self.log.debug(
"coll_start-coll_end: {}-{}".format(coll_start, coll_end))
self.log.info(
self.log.debug(
"len(collection.indexes): {}".format(collected_frames_len)
)

View file

@ -39,7 +39,7 @@ class RepairNukeWriteNodeAction(pyblish.api.Action):
set_node_knobs_from_settings(write_node, correct_data["knobs"])
self.log.info("Node attributes were fixed")
self.log.debug("Node attributes were fixed")
class ValidateNukeWriteNode(
@ -82,12 +82,6 @@ class ValidateNukeWriteNode(
correct_data = get_write_node_template_attr(write_group_node)
check = []
self.log.debug("__ write_node: {}".format(
write_node
))
self.log.debug("__ correct_data: {}".format(
correct_data
))
# Collect key values of same type in a list.
values_by_name = defaultdict(list)
@ -96,9 +90,6 @@ class ValidateNukeWriteNode(
for knob_data in correct_data["knobs"]:
knob_type = knob_data["type"]
self.log.debug("__ knob_type: {}".format(
knob_type
))
if (
knob_type == "__legacy__"
@ -134,9 +125,6 @@ class ValidateNukeWriteNode(
fixed_values.append(value)
self.log.debug("__ key: {} | values: {}".format(
key, fixed_values
))
if (
node_value not in fixed_values
and key != "file"
@ -144,8 +132,6 @@ class ValidateNukeWriteNode(
):
check.append([key, value, write_node[key].value()])
self.log.info(check)
if check:
self._make_error(check)

View file

@ -125,15 +125,19 @@ def get_any_timeline():
return project.GetTimelineByIndex(1)
def get_new_timeline():
def get_new_timeline(timeline_name: str = None):
"""Get new timeline object.
Arguments:
timeline_name (str): New timeline name.
Returns:
object: resolve.Timeline
"""
project = get_current_project()
media_pool = project.GetMediaPool()
new_timeline = media_pool.CreateEmptyTimeline(self.pype_timeline_name)
new_timeline = media_pool.CreateEmptyTimeline(
timeline_name or self.pype_timeline_name)
project.SetCurrentTimeline(new_timeline)
return new_timeline
@ -179,53 +183,52 @@ def create_bin(name: str, root: object = None) -> object:
return media_pool.GetCurrentFolder()
def create_media_pool_item(fpath: str,
root: object = None) -> object:
def remove_media_pool_item(media_pool_item: object) -> bool:
media_pool = get_current_project().GetMediaPool()
return media_pool.DeleteClips([media_pool_item])
def create_media_pool_item(
files: list,
root: object = None,
) -> object:
"""
Create media pool item.
Args:
fpath (str): absolute path to a file
files (list[str]): list of absolute paths to files
root (resolve.Folder)[optional]: root folder / bin object
Returns:
object: resolve.MediaPoolItem
"""
# get all variables
media_storage = get_media_storage()
media_pool = get_current_project().GetMediaPool()
root_bin = root or media_pool.GetRootFolder()
# make sure files list is not empty and first available file exists
filepath = next((f for f in files if os.path.isfile(f)), None)
if not filepath:
raise FileNotFoundError("No file found in input files list")
# try to search in bin if the clip does not exist
existing_mpi = get_media_pool_item(fpath, root_bin)
existing_mpi = get_media_pool_item(filepath, root_bin)
if existing_mpi:
return existing_mpi
dirname, file = os.path.split(fpath)
_name, ext = os.path.splitext(file)
# add all data in folder to media pool
media_pool_items = media_pool.ImportMedia(files)
# add all data in folder to mediapool
media_pool_items = media_storage.AddItemListToMediaPool(
os.path.normpath(dirname))
if not media_pool_items:
return False
# if any are added then look into them for the right extension
media_pool_item = [mpi for mpi in media_pool_items
if ext in mpi.GetClipProperty("File Path")]
# return only first found
return media_pool_item.pop()
return media_pool_items.pop() if media_pool_items else False
def get_media_pool_item(fpath, root: object = None) -> object:
def get_media_pool_item(filepath, root: object = None) -> object:
"""
Return clip if found in folder with use of input file path.
Args:
fpath (str): absolute path to a file
filepath (str): absolute path to a file
root (resolve.Folder)[optional]: root folder / bin object
Returns:
@ -233,7 +236,7 @@ def get_media_pool_item(fpath, root: object = None) -> object:
"""
media_pool = get_current_project().GetMediaPool()
root = root or media_pool.GetRootFolder()
fname = os.path.basename(fpath)
fname = os.path.basename(filepath)
for _mpi in root.GetClipList():
_mpi_name = _mpi.GetClipProperty("File Name")
@ -277,7 +280,6 @@ def create_timeline_item(media_pool_item: object,
if source_end is not None:
clip_data.update({"endFrame": source_end})
print(clip_data)
# add to timeline
media_pool.AppendToTimeline([clip_data])
@ -394,14 +396,22 @@ def get_current_timeline_items(
def get_pype_timeline_item_by_name(name: str) -> object:
track_itmes = get_current_timeline_items()
for _ti in track_itmes:
tag_data = get_timeline_item_pype_tag(_ti["clip"]["item"])
tag_name = tag_data.get("name")
"""Get timeline item by name.
Args:
name (str): name of timeline item
Returns:
object: resolve.TimelineItem
"""
for _ti_data in get_current_timeline_items():
_ti_clip = _ti_data["clip"]["item"]
tag_data = get_timeline_item_pype_tag(_ti_clip)
tag_name = tag_data.get("namespace")
if not tag_name:
continue
if tag_data.get("name") in name:
return _ti
if tag_name in name:
return _ti_clip
return None
@ -544,12 +554,11 @@ def set_pype_marker(timeline_item, tag_data):
def get_pype_marker(timeline_item):
timeline_item_markers = timeline_item.GetMarkers()
for marker_frame in timeline_item_markers:
note = timeline_item_markers[marker_frame]["note"]
color = timeline_item_markers[marker_frame]["color"]
name = timeline_item_markers[marker_frame]["name"]
print(f"_ marker data: {marker_frame} | {name} | {color} | {note}")
for marker_frame, marker in timeline_item_markers.items():
color = marker["color"]
name = marker["name"]
if name == self.pype_marker_name and color == self.pype_marker_color:
note = marker["note"]
self.temp_marker_frame = marker_frame
return json.loads(note)
@ -618,7 +627,7 @@ def create_compound_clip(clip_data, name, folder):
if c.GetName() in name), None)
if cct:
print(f"_ cct exists: {cct}")
print(f"Compound clip exists: {cct}")
else:
# Create empty timeline in current folder and give name:
cct = mp.CreateEmptyTimeline(name)
@ -627,7 +636,7 @@ def create_compound_clip(clip_data, name, folder):
clips = folder.GetClipList()
cct = next((c for c in clips
if c.GetName() in name), None)
print(f"_ cct created: {cct}")
print(f"Compound clip created: {cct}")
with maintain_current_timeline(cct, tl_origin):
# Add input clip to the current timeline:

View file

@ -127,10 +127,8 @@ def containerise(timeline_item,
})
if data:
for k, v in data.items():
data_imprint.update({k: v})
data_imprint.update(data)
print("_ data_imprint: {}".format(data_imprint))
lib.set_timeline_item_pype_tag(timeline_item, data_imprint)
return timeline_item

View file

@ -1,6 +1,5 @@
import re
import uuid
import qargparse
from qtpy import QtWidgets, QtCore
@ -9,6 +8,7 @@ from openpype.pipeline.context_tools import get_current_project_asset
from openpype.pipeline import (
LegacyCreator,
LoaderPlugin,
Anatomy
)
from . import lib
@ -291,20 +291,19 @@ class ClipLoader:
active_bin = None
data = dict()
def __init__(self, cls, context, path, **options):
def __init__(self, loader_obj, context, **options):
""" Initialize object
Arguments:
cls (openpype.pipeline.load.LoaderPlugin): plugin object
loader_obj (openpype.pipeline.load.LoaderPlugin): plugin object
context (dict): loader plugin context
options (dict)[optional]: possible keys:
projectBinPath: "path/to/binItem"
"""
self.__dict__.update(cls.__dict__)
self.__dict__.update(loader_obj.__dict__)
self.context = context
self.active_project = lib.get_current_project()
self.fname = path
# try to get value from options or evaluate key value for `handles`
self.with_handles = options.get("handles") or bool(
@ -319,54 +318,54 @@ class ClipLoader:
# inject asset data to representation dict
self._get_asset_data()
print("__init__ self.data: `{}`".format(self.data))
# add active components to class
if self.new_timeline:
if options.get("timeline"):
loader_cls = loader_obj.__class__
if loader_cls.timeline:
# if multiselection is set then use options sequence
self.active_timeline = options["timeline"]
self.active_timeline = loader_cls.timeline
else:
# create new sequence
self.active_timeline = (
lib.get_current_timeline() or
lib.get_new_timeline()
self.active_timeline = lib.get_new_timeline(
"{}_{}".format(
self.data["timeline_basename"],
str(uuid.uuid4())[:8]
)
)
loader_cls.timeline = self.active_timeline
else:
self.active_timeline = lib.get_current_timeline()
cls.timeline = self.active_timeline
def _populate_data(self):
""" Gets context and convert it to self.data
data structure:
{
"name": "assetName_subsetName_representationName"
"path": "path/to/file/created/by/get_repr..",
"binPath": "projectBinPath",
}
"""
# create name
repr = self.context["representation"]
repr_cntx = repr["context"]
asset = str(repr_cntx["asset"])
subset = str(repr_cntx["subset"])
representation = str(repr_cntx["representation"])
self.data["clip_name"] = "_".join([asset, subset, representation])
representation = self.context["representation"]
representation_context = representation["context"]
asset = str(representation_context["asset"])
subset = str(representation_context["subset"])
representation_name = str(representation_context["representation"])
self.data["clip_name"] = "_".join([
asset,
subset,
representation_name
])
self.data["versionData"] = self.context["version"]["data"]
# gets file path
file = self.fname
if not file:
repr_id = repr["_id"]
print(
"Representation id `{}` is failing to load".format(repr_id))
return None
self.data["path"] = file.replace("\\", "/")
self.data["timeline_basename"] = "timeline_{}_{}".format(
subset, representation_name)
# solve project bin structure path
hierarchy = str("/".join((
"Loader",
repr_cntx["hierarchy"].replace("\\", "/"),
representation_context["hierarchy"].replace("\\", "/"),
asset
)))
@ -383,25 +382,24 @@ class ClipLoader:
asset_name = self.context["representation"]["context"]["asset"]
self.data["assetData"] = get_current_project_asset(asset_name)["data"]
def load(self):
def load(self, files):
"""Load clip into timeline
Arguments:
files (list[str]): list of files to load into timeline
"""
# create project bin for the media to be imported into
self.active_bin = lib.create_bin(self.data["binPath"])
# create mediaItem in active project bin
# create clip media
handle_start = self.data["versionData"].get("handleStart") or 0
handle_end = self.data["versionData"].get("handleEnd") or 0
media_pool_item = lib.create_media_pool_item(
self.data["path"], self.active_bin)
files,
self.active_bin
)
_clip_property = media_pool_item.GetClipProperty
# get handles
handle_start = self.data["versionData"].get("handleStart")
handle_end = self.data["versionData"].get("handleEnd")
if handle_start is None:
handle_start = int(self.data["assetData"]["handleStart"])
if handle_end is None:
handle_end = int(self.data["assetData"]["handleEnd"])
source_in = int(_clip_property("Start"))
source_out = int(_clip_property("End"))
@ -421,14 +419,16 @@ class ClipLoader:
print("Loading clips: `{}`".format(self.data["clip_name"]))
return timeline_item
def update(self, timeline_item):
def update(self, timeline_item, files):
# create project bin for the media to be imported into
self.active_bin = lib.create_bin(self.data["binPath"])
# create mediaItem in active project bin
# create clip media
media_pool_item = lib.create_media_pool_item(
self.data["path"], self.active_bin)
files,
self.active_bin
)
_clip_property = media_pool_item.GetClipProperty
source_in = int(_clip_property("Start"))
@ -649,8 +649,6 @@ class PublishClip:
# define ui inputs if non gui mode was used
self.shot_num = self.ti_index
print(
"____ self.shot_num: {}".format(self.shot_num))
# ui_inputs data or default values if gui was not used
self.rename = self.ui_inputs.get(
@ -829,3 +827,12 @@ class PublishClip:
for key in par_split:
parent = self._convert_to_entity(key)
self.parents.append(parent)
def get_representation_files(representation):
anatomy = Anatomy()
files = []
for file_data in representation["files"]:
path = anatomy.fill_root(file_data["path"])
files.append(path)
return files

View file

@ -1,13 +1,7 @@
from copy import deepcopy
from openpype.client import (
get_version_by_id,
get_last_version_by_subset_id,
)
# from openpype.hosts import resolve
from openpype.client import get_last_version_by_subset_id
from openpype.pipeline import (
get_representation_path,
get_current_project_name,
get_representation_context,
get_current_project_name
)
from openpype.hosts.resolve.api import lib, plugin
from openpype.hosts.resolve.api.pipeline import (
@ -48,48 +42,17 @@ class LoadClip(plugin.TimelineItemLoader):
def load(self, context, name, namespace, options):
# in case loader uses multiselection
if self.timeline:
options.update({
"timeline": self.timeline,
})
# load clip to timeline and get main variables
path = self.filepath_from_context(context)
files = plugin.get_representation_files(context["representation"])
timeline_item = plugin.ClipLoader(
self, context, path, **options).load()
self, context, **options).load(files)
namespace = namespace or timeline_item.GetName()
version = context['version']
version_data = version.get("data", {})
version_name = version.get("name", None)
colorspace = version_data.get("colorspace", None)
object_name = "{}_{}".format(name, namespace)
# add additional metadata from the version to imprint Avalon knob
add_keys = [
"frameStart", "frameEnd", "source", "author",
"fps", "handleStart", "handleEnd"
]
# move all version data keys to tag data
data_imprint = {}
for key in add_keys:
data_imprint.update({
key: version_data.get(key, str(None))
})
# add variables related to version context
data_imprint.update({
"version": version_name,
"colorspace": colorspace,
"objectName": object_name
})
# update color of clip regarding the version order
self.set_item_color(timeline_item, version)
self.log.info("Loader done: `{}`".format(name))
self.set_item_color(timeline_item, version=context["version"])
data_imprint = self.get_tag_data(context, name, namespace)
return containerise(
timeline_item,
name, namespace, context,
@ -103,53 +66,61 @@ class LoadClip(plugin.TimelineItemLoader):
""" Updating previously loaded clips
"""
# load clip to timeline and get main variables
context = deepcopy(representation["context"])
context.update({"representation": representation})
context = get_representation_context(representation)
name = container['name']
namespace = container['namespace']
timeline_item_data = lib.get_pype_timeline_item_by_name(namespace)
timeline_item = timeline_item_data["clip"]["item"]
project_name = get_current_project_name()
version = get_version_by_id(project_name, representation["parent"])
timeline_item = container["_timeline_item"]
media_pool_item = timeline_item.GetMediaPoolItem()
files = plugin.get_representation_files(representation)
loader = plugin.ClipLoader(self, context)
timeline_item = loader.update(timeline_item, files)
# update color of clip regarding the version order
self.set_item_color(timeline_item, version=context["version"])
# if original media pool item has no remaining usages left
# remove it from the media pool
if int(media_pool_item.GetClipProperty("Usage")) == 0:
lib.remove_media_pool_item(media_pool_item)
data_imprint = self.get_tag_data(context, name, namespace)
return update_container(timeline_item, data_imprint)
def get_tag_data(self, context, name, namespace):
"""Return data to be imprinted on the timeline item marker"""
representation = context["representation"]
version = context['version']
version_data = version.get("data", {})
version_name = version.get("name", None)
colorspace = version_data.get("colorspace", None)
object_name = "{}_{}".format(name, namespace)
path = get_representation_path(representation)
context["version"] = {"data": version_data}
loader = plugin.ClipLoader(self, context, path)
timeline_item = loader.update(timeline_item)
# add additional metadata from the version to imprint Avalon knob
add_keys = [
# move all version data keys to tag data
add_version_data_keys = [
"frameStart", "frameEnd", "source", "author",
"fps", "handleStart", "handleEnd"
]
# move all version data keys to tag data
data_imprint = {}
for key in add_keys:
data_imprint.update({
key: version_data.get(key, str(None))
})
data = {
key: version_data.get(key, "None") for key in add_version_data_keys
}
# add variables related to version context
data_imprint.update({
data.update({
"representation": str(representation["_id"]),
"version": version_name,
"colorspace": colorspace,
"objectName": object_name
})
# update color of clip regarding the version order
self.set_item_color(timeline_item, version)
return update_container(timeline_item, data_imprint)
return data
@classmethod
def set_item_color(cls, timeline_item, version):
"""Color timeline item based on whether it is outdated or latest"""
# define version name
version_name = version.get("name", None)
# get all versions in list
@ -169,3 +140,28 @@ class LoadClip(plugin.TimelineItemLoader):
timeline_item.SetClipColor(cls.clip_color_last)
else:
timeline_item.SetClipColor(cls.clip_color)
def remove(self, container):
timeline_item = container["_timeline_item"]
media_pool_item = timeline_item.GetMediaPoolItem()
timeline = lib.get_current_timeline()
# DeleteClips function was added in Resolve 18.5+
# by checking None we can detect whether the
# function exists in Resolve
if timeline.DeleteClips is not None:
timeline.DeleteClips([timeline_item])
else:
# Resolve versions older than 18.5 can't delete clips via API
# so all we can do is just remove the pype marker to 'untag' it
if lib.get_pype_marker(timeline_item):
# Note: We must call `get_pype_marker` because
# `delete_pype_marker` uses a global variable set by
# `get_pype_marker` to delete the right marker
# TODO: Improve code to avoid the global `temp_marker_frame`
lib.delete_pype_marker(timeline_item)
# if media pool item has no remaining usages left
# remove it from the media pool
if int(media_pool_item.GetClipProperty("Usage")) == 0:
lib.remove_media_pool_item(media_pool_item)

View file

@ -1,49 +0,0 @@
#! python3
import os
import sys
import opentimelineio as otio
from openpype.pipeline import install_host
import openpype.hosts.resolve.api as bmdvr
from openpype.hosts.resolve.api.testing_utils import TestGUI
from openpype.hosts.resolve.otio import davinci_export as otio_export
class ThisTestGUI(TestGUI):
extensions = [".exr", ".jpg", ".mov", ".png", ".mp4", ".ari", ".arx"]
def __init__(self):
super(ThisTestGUI, self).__init__()
# activate resolve from openpype
install_host(bmdvr)
def _open_dir_button_pressed(self, event):
# selected_path = self.fu.RequestFile(os.path.expanduser("~"))
selected_path = self.fu.RequestDir(os.path.expanduser("~"))
self._widgets["inputTestSourcesFolder"].Text = selected_path
# main function
def process(self, event):
self.input_dir_path = self._widgets["inputTestSourcesFolder"].Text
project = bmdvr.get_current_project()
otio_timeline = otio_export.create_otio_timeline(project)
print(f"_ otio_timeline: `{otio_timeline}`")
edl_path = os.path.join(self.input_dir_path, "this_file_name.edl")
print(f"_ edl_path: `{edl_path}`")
# xml_string = otio_adapters.fcpx_xml.write_to_string(otio_timeline)
# print(f"_ xml_string: `{xml_string}`")
otio.adapters.write_to_file(
otio_timeline, edl_path, adapter_name="cmx_3600")
project = bmdvr.get_current_project()
media_pool = project.GetMediaPool()
timeline = media_pool.ImportTimelineFromFile(edl_path)
# at the end close the window
self._close_window(None)
if __name__ == "__main__":
test_gui = ThisTestGUI()
test_gui.show_gui()
sys.exit(not bool(True))

View file

@ -1,73 +0,0 @@
#! python3
import os
import sys
import clique
from openpype.pipeline import install_host
from openpype.hosts.resolve.api.testing_utils import TestGUI
import openpype.hosts.resolve.api as bmdvr
from openpype.hosts.resolve.api.lib import (
create_media_pool_item,
create_timeline_item,
)
class ThisTestGUI(TestGUI):
extensions = [".exr", ".jpg", ".mov", ".png", ".mp4", ".ari", ".arx"]
def __init__(self):
super(ThisTestGUI, self).__init__()
# activate resolve from openpype
install_host(bmdvr)
def _open_dir_button_pressed(self, event):
# selected_path = self.fu.RequestFile(os.path.expanduser("~"))
selected_path = self.fu.RequestDir(os.path.expanduser("~"))
self._widgets["inputTestSourcesFolder"].Text = selected_path
# main function
def process(self, event):
self.input_dir_path = self._widgets["inputTestSourcesFolder"].Text
self.dir_processing(self.input_dir_path)
# at the end close the window
self._close_window(None)
def dir_processing(self, dir_path):
collections, reminders = clique.assemble(os.listdir(dir_path))
# process reminders
for _rem in reminders:
_rem_path = os.path.join(dir_path, _rem)
# go deeper if directory
if os.path.isdir(_rem_path):
print(_rem_path)
self.dir_processing(_rem_path)
else:
self.file_processing(_rem_path)
# process collections
for _coll in collections:
_coll_path = os.path.join(dir_path, list(_coll).pop())
self.file_processing(_coll_path)
def file_processing(self, fpath):
print(f"_ fpath: `{fpath}`")
_base, ext = os.path.splitext(fpath)
# skip if unwanted extension
if ext not in self.extensions:
return
media_pool_item = create_media_pool_item(fpath)
print(media_pool_item)
track_item = create_timeline_item(media_pool_item)
print(track_item)
if __name__ == "__main__":
test_gui = ThisTestGUI()
test_gui.show_gui()
sys.exit(not bool(True))

View file

@ -1,24 +0,0 @@
#! python3
from openpype.pipeline import install_host
from openpype.hosts.resolve import api as bmdvr
from openpype.hosts.resolve.api.lib import (
create_media_pool_item,
create_timeline_item,
)
def file_processing(fpath):
media_pool_item = create_media_pool_item(fpath)
print(media_pool_item)
track_item = create_timeline_item(media_pool_item)
print(track_item)
if __name__ == "__main__":
path = "C:/CODE/__openpype_projects/jtest03dev/shots/sq01/mainsq01sh030/publish/plate/plateMain/v006/jt3d_mainsq01sh030_plateMain_v006.0996.exr"
# activate resolve from openpype
install_host(bmdvr)
file_processing(path)

View file

@ -1,5 +0,0 @@
#! python3
from openpype.hosts.resolve.startup import main
if __name__ == "__main__":
main()

View file

@ -1,13 +0,0 @@
#! python3
from openpype.pipeline import install_host
from openpype.hosts.resolve import api as bmdvr
from openpype.hosts.resolve.api.lib import get_current_project
if __name__ == "__main__":
install_host(bmdvr)
project = get_current_project()
timeline_count = project.GetTimelineCount()
print(f"Timeline count: {timeline_count}")
timeline = project.GetTimelineByIndex(timeline_count)
print(f"Timeline name: {timeline.GetName()}")
print(timeline.GetTrackCount("video"))

View file

@ -13,7 +13,8 @@ from openpype.pipeline.publish import (
)
from openpype.lib import (
BoolDef,
NumberDef
NumberDef,
is_running_from_build
)
@ -230,6 +231,11 @@ class FusionSubmitDeadline(
"OPENPYPE_LOG_NO_COLORS",
"IS_TEST"
]
# Add OpenPype version if we are running from build.
if is_running_from_build():
keys.append("OPENPYPE_VERSION")
environment = dict({key: os.environ[key] for key in keys
if key in os.environ}, **legacy_io.Session)

View file

@ -44,19 +44,25 @@ class CollectFtrackApi(pyblish.api.ContextPlugin):
self.log.debug("Project found: {0}".format(project_entity))
task_object_type = session.query(
"ObjectType where name is 'Task'").one()
task_object_type_id = task_object_type["id"]
asset_entity = None
if asset_name:
# Find asset entity
entity_query = (
'TypedContext where project_id is "{0}"'
' and name is "{1}"'
).format(project_entity["id"], asset_name)
"TypedContext where project_id is '{}'"
" and name is '{}'"
" and object_type_id != '{}'"
).format(
project_entity["id"],
asset_name,
task_object_type_id
)
self.log.debug("Asset entity query: < {0} >".format(entity_query))
asset_entities = []
for entity in session.query(entity_query).all():
# Skip tasks
if entity.entity_type.lower() != "task":
asset_entities.append(entity)
asset_entities.append(entity)
if len(asset_entities) == 0:
raise AssertionError((
@ -103,10 +109,19 @@ class CollectFtrackApi(pyblish.api.ContextPlugin):
context.data["ftrackEntity"] = asset_entity
context.data["ftrackTask"] = task_entity
self.per_instance_process(context, asset_entity, task_entity)
self.per_instance_process(
context,
asset_entity,
task_entity,
task_object_type_id
)
def per_instance_process(
self, context, context_asset_entity, context_task_entity
self,
context,
context_asset_entity,
context_task_entity,
task_object_type_id
):
context_task_name = None
context_asset_name = None
@ -182,23 +197,27 @@ class CollectFtrackApi(pyblish.api.ContextPlugin):
session = context.data["ftrackSession"]
project_entity = context.data["ftrackProject"]
asset_names = set()
for asset_name in instance_by_asset_and_task.keys():
asset_names.add(asset_name)
asset_names = set(instance_by_asset_and_task.keys())
joined_asset_names = ",".join([
"\"{}\"".format(name)
for name in asset_names
])
entities = session.query((
"TypedContext where project_id is \"{}\" and name in ({})"
).format(project_entity["id"], joined_asset_names)).all()
entities = session.query(
(
"TypedContext where project_id is \"{}\" and name in ({})"
" and object_type_id != '{}'"
).format(
project_entity["id"],
joined_asset_names,
task_object_type_id
)
).all()
entities_by_name = {
entity["name"]: entity
for entity in entities
}
for asset_name, by_task_data in instance_by_asset_and_task.items():
entity = entities_by_name.get(asset_name)
task_entity_by_name = {}

View file

@ -14,6 +14,13 @@ class TaskNotSetError(KeyError):
super(TaskNotSetError, self).__init__(msg)
class TemplateFillError(Exception):
def __init__(self, msg=None):
if not msg:
msg = "Creator's subset name template is missing key value."
super(TemplateFillError, self).__init__(msg)
def get_subset_name_template(
project_name,
family,
@ -112,6 +119,10 @@ def get_subset_name(
for project. Settings are queried if not passed.
family_filter (Optional[str]): Use different family for subset template
filtering. Value of 'family' is used when not passed.
Raises:
TemplateFillError: If filled template contains placeholder key which is not
collected.
"""
if not family:
@ -154,4 +165,10 @@ def get_subset_name(
for key, value in dynamic_data.items():
fill_pairs[key] = value
return template.format(**prepare_template_data(fill_pairs))
try:
return template.format(**prepare_template_data(fill_pairs))
except KeyError as exp:
raise TemplateFillError(
"Value for {} key is missing in template '{}'."
" Available values are {}".format(str(exp), template, fill_pairs)
)

View file

@ -17,7 +17,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
"""Create jpg thumbnail from sequence using ffmpeg"""
label = "Extract Thumbnail"
order = pyblish.api.ExtractorOrder
order = pyblish.api.ExtractorOrder + 0.49
families = [
"imagesequence", "render", "render2d", "prerender",
"source", "clip", "take", "online", "image"

View file

@ -341,7 +341,7 @@
"write"
]
},
"ValidateCorrectAssetName": {
"ValidateCorrectAssetContext": {
"enabled": true,
"optional": true,
"active": true

View file

@ -12,6 +12,26 @@
"LC_ALL": "C"
},
"variants": {
"2024": {
"use_python_2": false,
"executables": {
"windows": [
"C:\\Program Files\\Autodesk\\Maya2024\\bin\\maya.exe"
],
"darwin": [],
"linux": [
"/usr/autodesk/maya2024/bin/maya"
]
},
"arguments": {
"windows": [],
"darwin": [],
"linux": []
},
"environment": {
"MAYA_VERSION": "2024"
}
},
"2023": {
"use_python_2": false,
"executables": {
@ -51,66 +71,6 @@
"environment": {
"MAYA_VERSION": "2022"
}
},
"2020": {
"use_python_2": true,
"executables": {
"windows": [
"C:\\Program Files\\Autodesk\\Maya2020\\bin\\maya.exe"
],
"darwin": [],
"linux": [
"/usr/autodesk/maya2020/bin/maya"
]
},
"arguments": {
"windows": [],
"darwin": [],
"linux": []
},
"environment": {
"MAYA_VERSION": "2020"
}
},
"2019": {
"use_python_2": true,
"executables": {
"windows": [
"C:\\Program Files\\Autodesk\\Maya2019\\bin\\maya.exe"
],
"darwin": [],
"linux": [
"/usr/autodesk/maya2019/bin/maya"
]
},
"arguments": {
"windows": [],
"darwin": [],
"linux": []
},
"environment": {
"MAYA_VERSION": "2019"
}
},
"2018": {
"use_python_2": true,
"executables": {
"windows": [
"C:\\Program Files\\Autodesk\\Maya2018\\bin\\maya.exe"
],
"darwin": [],
"linux": [
"/usr/autodesk/maya2018/bin/maya"
]
},
"arguments": {
"windows": [],
"darwin": [],
"linux": []
},
"environment": {
"MAYA_VERSION": "2018"
}
}
}
},

View file

@ -61,7 +61,7 @@
"name": "template_publish_plugin",
"template_data": [
{
"key": "ValidateCorrectAssetName",
"key": "ValidateCorrectAssetContext",
"label": "Validate Correct Asset Name"
}
]

View file

@ -103,4 +103,4 @@ class HierarchyPage(QtWidgets.QWidget):
self._controller.refresh()
def _on_filter_text_changed(self, text):
self._folders_widget.set_name_filer(text)
self._folders_widget.set_name_filter(text)

View file

@ -11,14 +11,14 @@ from openpype.tools.ayon_utils.widgets import (
FoldersModel,
FOLDERS_MODEL_SENDER_NAME,
)
from openpype.tools.ayon_utils.widgets.folders_widget import ITEM_ID_ROLE
from openpype.tools.ayon_utils.widgets.folders_widget import FOLDER_ID_ROLE
if qtpy.API == "pyside":
from PySide.QtGui import QStyleOptionViewItemV4
elif qtpy.API == "pyqt4":
from PyQt4.QtGui import QStyleOptionViewItemV4
UNDERLINE_COLORS_ROLE = QtCore.Qt.UserRole + 4
UNDERLINE_COLORS_ROLE = QtCore.Qt.UserRole + 50
class UnderlinesFolderDelegate(QtWidgets.QItemDelegate):
@ -257,13 +257,11 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
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.
"""
refreshed = QtCore.Signal()
def __init__(self, controller, parent, handle_expected_selection=False):
def __init__(self, controller, parent):
super(LoaderFoldersWidget, self).__init__(parent)
folders_view = DeselectableTreeView(self)
@ -313,10 +311,9 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
self._folders_proxy_model = folders_proxy_model
self._folders_label_delegate = folders_label_delegate
self._handle_expected_selection = handle_expected_selection
self._expected_selection = None
def set_name_filer(self, name):
def set_name_filter(self, name):
"""Set filter of folder name.
Args:
@ -365,7 +362,7 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
selection_model = self._folders_view.selectionModel()
item_ids = []
for index in selection_model.selectedIndexes():
item_id = index.data(ITEM_ID_ROLE)
item_id = index.data(FOLDER_ID_ROLE)
if item_id is not None:
item_ids.append(item_id)
return item_ids
@ -379,9 +376,6 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
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()
@ -395,9 +389,6 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
self._set_expected_selection()
def _set_expected_selection(self):
if not self._handle_expected_selection:
return
folder_id = self._expected_selection
selected_ids = self._get_selected_item_ids()
self._expected_selection = None

View file

@ -183,7 +183,7 @@ class ProductsWidget(QtWidgets.QWidget):
not controller.is_loaded_products_supported()
)
def set_name_filer(self, name):
def set_name_filter(self, name):
"""Set filter of product name.
Args:

View file

@ -382,7 +382,7 @@ class LoaderWindow(QtWidgets.QWidget):
self._controller.reset()
def _show_group_dialog(self):
project_name = self._projects_combobox.get_current_project_name()
project_name = self._projects_combobox.get_selected_project_name()
if not project_name:
return
@ -397,7 +397,7 @@ class LoaderWindow(QtWidgets.QWidget):
self._group_dialog.show()
def _on_folder_filter_change(self, text):
self._folders_widget.set_name_filer(text)
self._folders_widget.set_name_filter(text)
def _on_product_group_change(self):
self._products_widget.set_enable_grouping(
@ -405,7 +405,7 @@ class LoaderWindow(QtWidgets.QWidget):
)
def _on_product_filter_change(self, text):
self._products_widget.set_name_filer(text)
self._products_widget.set_name_filter(text)
def _on_product_type_filter_change(self):
self._products_widget.set_product_type_filter(
@ -419,7 +419,7 @@ class LoaderWindow(QtWidgets.QWidget):
def _on_products_selection_change(self):
items = self._products_widget.get_selected_version_info()
self._info_widget.set_selected_version_info(
self._projects_combobox.get_current_project_name(),
self._projects_combobox.get_selected_project_name(),
items
)

View file

@ -0,0 +1,6 @@
from .control import SceneInventoryController
__all__ = (
"SceneInventoryController",
)

View file

@ -0,0 +1,134 @@
import ayon_api
from openpype.lib.events import QueuedEventSystem
from openpype.host import ILoadHost
from openpype.pipeline import (
registered_host,
get_current_context,
)
from openpype.tools.ayon_utils.models import HierarchyModel
from .models import SiteSyncModel
class SceneInventoryController:
"""This is a temporary controller for AYON.
Goal of this temporary controller is to provide a way to get current
context instead of using 'AvalonMongoDB' object (or 'legacy_io').
Also provides (hopefully) cleaner api for site sync.
"""
def __init__(self, host=None):
if host is None:
host = registered_host()
self._host = host
self._current_context = None
self._current_project = None
self._current_folder_id = None
self._current_folder_set = False
self._site_sync_model = SiteSyncModel(self)
# Switch dialog requirements
self._hierarchy_model = HierarchyModel(self)
self._event_system = self._create_event_system()
def emit_event(self, topic, data=None, source=None):
if data is None:
data = {}
self._event_system.emit(topic, data, source)
def register_event_callback(self, topic, callback):
self._event_system.add_callback(topic, callback)
def reset(self):
self._current_context = None
self._current_project = None
self._current_folder_id = None
self._current_folder_set = False
self._site_sync_model.reset()
self._hierarchy_model.reset()
def get_current_context(self):
if self._current_context is None:
if hasattr(self._host, "get_current_context"):
self._current_context = self._host.get_current_context()
else:
self._current_context = get_current_context()
return self._current_context
def get_current_project_name(self):
if self._current_project is None:
self._current_project = self.get_current_context()["project_name"]
return self._current_project
def get_current_folder_id(self):
if self._current_folder_set:
return self._current_folder_id
context = self.get_current_context()
project_name = context["project_name"]
folder_path = context.get("folder_path")
folder_name = context.get("asset_name")
folder_id = None
if folder_path:
folder = ayon_api.get_folder_by_path(project_name, folder_path)
if folder:
folder_id = folder["id"]
elif folder_name:
for folder in ayon_api.get_folders(
project_name, folder_names=[folder_name]
):
folder_id = folder["id"]
break
self._current_folder_id = folder_id
self._current_folder_set = True
return self._current_folder_id
def get_containers(self):
host = self._host
if isinstance(host, ILoadHost):
return host.get_containers()
elif hasattr(host, "ls"):
return host.ls()
return []
# Site Sync methods
def is_sync_server_enabled(self):
return self._site_sync_model.is_sync_server_enabled()
def get_sites_information(self):
return self._site_sync_model.get_sites_information()
def get_site_provider_icons(self):
return self._site_sync_model.get_site_provider_icons()
def get_representations_site_progress(self, representation_ids):
return self._site_sync_model.get_representations_site_progress(
representation_ids
)
def resync_representations(self, representation_ids, site_type):
return self._site_sync_model.resync_representations(
representation_ids, site_type
)
# Switch dialog methods
def get_folder_items(self, project_name, sender=None):
return self._hierarchy_model.get_folder_items(project_name, sender)
def get_folder_label(self, folder_id):
if not folder_id:
return None
project_name = self.get_current_project_name()
folder_item = self._hierarchy_model.get_folder_item(
project_name, folder_id)
if folder_item is None:
return None
return folder_item.label
def _create_event_system(self):
return QueuedEventSystem()

View file

@ -0,0 +1,622 @@
import collections
import re
import logging
import uuid
import copy
from collections import defaultdict
from qtpy import QtCore, QtGui
import qtawesome
from openpype.client import (
get_assets,
get_subsets,
get_versions,
get_last_version_by_subset_id,
get_representations,
)
from openpype.pipeline import (
get_current_project_name,
schema,
HeroVersionType,
)
from openpype.style import get_default_entity_icon_color
from openpype.tools.utils.models import TreeModel, Item
def walk_hierarchy(node):
"""Recursively yield group node."""
for child in node.children():
if child.get("isGroupNode"):
yield child
for _child in walk_hierarchy(child):
yield _child
class InventoryModel(TreeModel):
"""The model for the inventory"""
Columns = [
"Name",
"version",
"count",
"family",
"group",
"loader",
"objectName",
"active_site",
"remote_site",
]
active_site_col = Columns.index("active_site")
remote_site_col = Columns.index("remote_site")
OUTDATED_COLOR = QtGui.QColor(235, 30, 30)
CHILD_OUTDATED_COLOR = QtGui.QColor(200, 160, 30)
GRAYOUT_COLOR = QtGui.QColor(160, 160, 160)
UniqueRole = QtCore.Qt.UserRole + 2 # unique label role
def __init__(self, controller, parent=None):
super(InventoryModel, self).__init__(parent)
self.log = logging.getLogger(self.__class__.__name__)
self._controller = controller
self._hierarchy_view = False
self._default_icon_color = get_default_entity_icon_color()
site_icons = self._controller.get_site_provider_icons()
self._site_icons = {
provider: QtGui.QIcon(icon_path)
for provider, icon_path in site_icons.items()
}
def outdated(self, item):
value = item.get("version")
if isinstance(value, HeroVersionType):
return False
if item.get("version") == item.get("highest_version"):
return False
return True
def data(self, index, role):
if not index.isValid():
return
item = index.internalPointer()
if role == QtCore.Qt.FontRole:
# Make top-level entries bold
if item.get("isGroupNode") or item.get("isNotSet"): # group-item
font = QtGui.QFont()
font.setBold(True)
return font
if role == QtCore.Qt.ForegroundRole:
# Set the text color to the OUTDATED_COLOR when the
# collected version is not the same as the highest version
key = self.Columns[index.column()]
if key == "version": # version
if item.get("isGroupNode"): # group-item
if self.outdated(item):
return self.OUTDATED_COLOR
if self._hierarchy_view:
# If current group is not outdated, check if any
# outdated children.
for _node in walk_hierarchy(item):
if self.outdated(_node):
return self.CHILD_OUTDATED_COLOR
else:
if self._hierarchy_view:
# Although this is not a group item, we still need
# to distinguish which one contain outdated child.
for _node in walk_hierarchy(item):
if self.outdated(_node):
return self.CHILD_OUTDATED_COLOR.darker(150)
return self.GRAYOUT_COLOR
if key == "Name" and not item.get("isGroupNode"):
return self.GRAYOUT_COLOR
# Add icons
if role == QtCore.Qt.DecorationRole:
if index.column() == 0:
# Override color
color = item.get("color", self._default_icon_color)
if item.get("isGroupNode"): # group-item
return qtawesome.icon("fa.folder", color=color)
if item.get("isNotSet"):
return qtawesome.icon("fa.exclamation-circle", color=color)
return qtawesome.icon("fa.file-o", color=color)
if index.column() == 3:
# Family icon
return item.get("familyIcon", None)
column_name = self.Columns[index.column()]
if column_name == "group" and item.get("group"):
return qtawesome.icon("fa.object-group",
color=get_default_entity_icon_color())
if item.get("isGroupNode"):
if column_name == "active_site":
provider = item.get("active_site_provider")
return self._site_icons.get(provider)
if column_name == "remote_site":
provider = item.get("remote_site_provider")
return self._site_icons.get(provider)
if role == QtCore.Qt.DisplayRole and item.get("isGroupNode"):
column_name = self.Columns[index.column()]
progress = None
if column_name == "active_site":
progress = item.get("active_site_progress", 0)
elif column_name == "remote_site":
progress = item.get("remote_site_progress", 0)
if progress is not None:
return "{}%".format(max(progress, 0) * 100)
if role == self.UniqueRole:
return item["representation"] + item.get("objectName", "<none>")
return super(InventoryModel, self).data(index, role)
def set_hierarchy_view(self, state):
"""Set whether to display subsets in hierarchy view."""
state = bool(state)
if state != self._hierarchy_view:
self._hierarchy_view = state
def refresh(self, selected=None, containers=None):
"""Refresh the model"""
# for debugging or testing, injecting items from outside
if containers is None:
containers = self._controller.get_containers()
self.clear()
if not selected or not self._hierarchy_view:
self._add_containers(containers)
return
# Filter by cherry-picked items
self._add_containers((
container
for container in containers
if container["objectName"] in selected
))
def _add_containers(self, containers, parent=None):
"""Add the items to the model.
The items should be formatted similar to `api.ls()` returns, an item
is then represented as:
{"filename_v001.ma": [full/filename/of/loaded/filename_v001.ma,
full/filename/of/loaded/filename_v001.ma],
"nodetype" : "reference",
"node": "referenceNode1"}
Note: When performing an additional call to `add_items` it will *not*
group the new items with previously existing item groups of the
same type.
Args:
containers (generator): Container items.
parent (Item, optional): Set this item as parent for the added
items when provided. Defaults to the root of the model.
Returns:
node.Item: root node which has children added based on the data
"""
project_name = get_current_project_name()
self.beginResetModel()
# Group by representation
grouped = defaultdict(lambda: {"containers": list()})
for container in containers:
repre_id = container["representation"]
grouped[repre_id]["containers"].append(container)
(
repres_by_id,
versions_by_id,
products_by_id,
folders_by_id,
) = self._query_entities(project_name, set(grouped.keys()))
# Add to model
not_found = defaultdict(list)
not_found_ids = []
for repre_id, group_dict in sorted(grouped.items()):
group_containers = group_dict["containers"]
representation = repres_by_id.get(repre_id)
if not representation:
not_found["representation"].extend(group_containers)
not_found_ids.append(repre_id)
continue
version = versions_by_id.get(representation["parent"])
if not version:
not_found["version"].extend(group_containers)
not_found_ids.append(repre_id)
continue
product = products_by_id.get(version["parent"])
if not product:
not_found["product"].extend(group_containers)
not_found_ids.append(repre_id)
continue
folder = folders_by_id.get(product["parent"])
if not folder:
not_found["folder"].extend(group_containers)
not_found_ids.append(repre_id)
continue
group_dict.update({
"representation": representation,
"version": version,
"subset": product,
"asset": folder
})
for _repre_id in not_found_ids:
grouped.pop(_repre_id)
for where, group_containers in not_found.items():
# create the group header
group_node = Item()
name = "< NOT FOUND - {} >".format(where)
group_node["Name"] = name
group_node["representation"] = name
group_node["count"] = len(group_containers)
group_node["isGroupNode"] = False
group_node["isNotSet"] = True
self.add_child(group_node, parent=parent)
for container in group_containers:
item_node = Item()
item_node.update(container)
item_node["Name"] = container.get("objectName", "NO NAME")
item_node["isNotFound"] = True
self.add_child(item_node, parent=group_node)
# TODO Use product icons
family_icon = qtawesome.icon(
"fa.folder", color="#0091B2"
)
# Prepare site sync specific data
progress_by_id = self._controller.get_representations_site_progress(
set(grouped.keys())
)
sites_info = self._controller.get_sites_information()
for repre_id, group_dict in sorted(grouped.items()):
group_containers = group_dict["containers"]
representation = group_dict["representation"]
version = group_dict["version"]
subset = group_dict["subset"]
asset = group_dict["asset"]
# Get the primary family
maj_version, _ = schema.get_schema_version(subset["schema"])
if maj_version < 3:
src_doc = version
else:
src_doc = subset
prim_family = src_doc["data"].get("family")
if not prim_family:
families = src_doc["data"].get("families")
if families:
prim_family = families[0]
# Store the highest available version so the model can know
# whether current version is currently up-to-date.
highest_version = get_last_version_by_subset_id(
project_name, version["parent"]
)
# create the group header
group_node = Item()
group_node["Name"] = "{}_{}: ({})".format(
asset["name"], subset["name"], representation["name"]
)
group_node["representation"] = repre_id
group_node["version"] = version["name"]
group_node["highest_version"] = highest_version["name"]
group_node["family"] = prim_family or ""
group_node["familyIcon"] = family_icon
group_node["count"] = len(group_containers)
group_node["isGroupNode"] = True
group_node["group"] = subset["data"].get("subsetGroup")
# Site sync specific data
progress = progress_by_id[repre_id]
group_node.update(sites_info)
group_node["active_site_progress"] = progress["active_site"]
group_node["remote_site_progress"] = progress["remote_site"]
self.add_child(group_node, parent=parent)
for container in group_containers:
item_node = Item()
item_node.update(container)
# store the current version on the item
item_node["version"] = version["name"]
# Remapping namespace to item name.
# Noted that the name key is capital "N", by doing this, we
# can view namespace in GUI without changing container data.
item_node["Name"] = container["namespace"]
self.add_child(item_node, parent=group_node)
self.endResetModel()
return self._root_item
def _query_entities(self, project_name, repre_ids):
"""Query entities for representations from containers.
Returns:
tuple[dict, dict, dict, dict]: Representation, version, product
and folder documents by id.
"""
repres_by_id = {}
versions_by_id = {}
products_by_id = {}
folders_by_id = {}
output = (
repres_by_id,
versions_by_id,
products_by_id,
folders_by_id,
)
filtered_repre_ids = set()
for repre_id in repre_ids:
# Filter out invalid representation ids
# NOTE: This is added because scenes from OpenPype did contain
# ObjectId from mongo.
try:
uuid.UUID(repre_id)
filtered_repre_ids.add(repre_id)
except ValueError:
continue
if not filtered_repre_ids:
return output
repre_docs = get_representations(project_name, repre_ids)
repres_by_id.update({
repre_doc["_id"]: repre_doc
for repre_doc in repre_docs
})
version_ids = {
repre_doc["parent"] for repre_doc in repres_by_id.values()
}
if not version_ids:
return output
version_docs = get_versions(project_name, version_ids, hero=True)
versions_by_id.update({
version_doc["_id"]: version_doc
for version_doc in version_docs
})
hero_versions_by_subversion_id = collections.defaultdict(list)
for version_doc in versions_by_id.values():
if version_doc["type"] != "hero_version":
continue
subversion = version_doc["version_id"]
hero_versions_by_subversion_id[subversion].append(version_doc)
if hero_versions_by_subversion_id:
subversion_ids = set(
hero_versions_by_subversion_id.keys()
)
subversion_docs = get_versions(project_name, subversion_ids)
for subversion_doc in subversion_docs:
subversion_id = subversion_doc["_id"]
subversion_ids.discard(subversion_id)
h_version_docs = hero_versions_by_subversion_id[subversion_id]
for version_doc in h_version_docs:
version_doc["name"] = HeroVersionType(
subversion_doc["name"]
)
version_doc["data"] = copy.deepcopy(
subversion_doc["data"]
)
for subversion_id in subversion_ids:
h_version_docs = hero_versions_by_subversion_id[subversion_id]
for version_doc in h_version_docs:
versions_by_id.pop(version_doc["_id"])
product_ids = {
version_doc["parent"]
for version_doc in versions_by_id.values()
}
if not product_ids:
return output
product_docs = get_subsets(project_name, product_ids)
products_by_id.update({
product_doc["_id"]: product_doc
for product_doc in product_docs
})
folder_ids = {
product_doc["parent"]
for product_doc in products_by_id.values()
}
if not folder_ids:
return output
folder_docs = get_assets(project_name, folder_ids)
folders_by_id.update({
folder_doc["_id"]: folder_doc
for folder_doc in folder_docs
})
return output
class FilterProxyModel(QtCore.QSortFilterProxyModel):
"""Filter model to where key column's value is in the filtered tags"""
def __init__(self, *args, **kwargs):
super(FilterProxyModel, self).__init__(*args, **kwargs)
self._filter_outdated = False
self._hierarchy_view = False
def filterAcceptsRow(self, row, parent):
model = self.sourceModel()
source_index = model.index(row, self.filterKeyColumn(), parent)
# Always allow bottom entries (individual containers), since their
# parent group hidden if it wouldn't have been validated.
rows = model.rowCount(source_index)
if not rows:
return True
# Filter by regex
if hasattr(self, "filterRegExp"):
regex = self.filterRegExp()
else:
regex = self.filterRegularExpression()
pattern = regex.pattern()
if pattern:
pattern = re.escape(pattern)
if not self._matches(row, parent, pattern):
return False
if self._filter_outdated:
# When filtering to outdated we filter the up to date entries
# thus we "allow" them when they are outdated
if not self._is_outdated(row, parent):
return False
return True
def set_filter_outdated(self, state):
"""Set whether to show the outdated entries only."""
state = bool(state)
if state != self._filter_outdated:
self._filter_outdated = bool(state)
self.invalidateFilter()
def set_hierarchy_view(self, state):
state = bool(state)
if state != self._hierarchy_view:
self._hierarchy_view = state
def _is_outdated(self, row, parent):
"""Return whether row is outdated.
A row is considered outdated if it has "version" and "highest_version"
data and in the internal data structure, and they are not of an
equal value.
"""
def outdated(node):
version = node.get("version", None)
highest = node.get("highest_version", None)
# Always allow indices that have no version data at all
if version is None and highest is None:
return True
# If either a version or highest is present but not the other
# consider the item invalid.
if not self._hierarchy_view:
# Skip this check if in hierarchy view, or the child item
# node will be hidden even it's actually outdated.
if version is None or highest is None:
return False
return version != highest
index = self.sourceModel().index(row, self.filterKeyColumn(), parent)
# The scene contents are grouped by "representation", e.g. the same
# "representation" loaded twice is grouped under the same header.
# Since the version check filters these parent groups we skip that
# check for the individual children.
has_parent = index.parent().isValid()
if has_parent and not self._hierarchy_view:
return True
# Filter to those that have the different version numbers
node = index.internalPointer()
if outdated(node):
return True
if self._hierarchy_view:
for _node in walk_hierarchy(node):
if outdated(_node):
return True
return False
def _matches(self, row, parent, pattern):
"""Return whether row matches regex pattern.
Args:
row (int): row number in model
parent (QtCore.QModelIndex): parent index
pattern (regex.pattern): pattern to check for in key
Returns:
bool
"""
model = self.sourceModel()
column = self.filterKeyColumn()
role = self.filterRole()
def matches(row, parent, pattern):
index = model.index(row, column, parent)
key = model.data(index, role)
if re.search(pattern, key, re.IGNORECASE):
return True
if matches(row, parent, pattern):
return True
# Also allow if any of the children matches
source_index = model.index(row, column, parent)
rows = model.rowCount(source_index)
if any(
matches(idx, source_index, pattern)
for idx in range(rows)
):
return True
if not self._hierarchy_view:
return False
for idx in range(rows):
child_index = model.index(idx, column, source_index)
child_rows = model.rowCount(child_index)
return any(
self._matches(child_idx, child_index, pattern)
for child_idx in range(child_rows)
)
return True

View file

@ -0,0 +1,6 @@
from .site_sync import SiteSyncModel
__all__ = (
"SiteSyncModel",
)

View file

@ -0,0 +1,176 @@
from openpype.client import get_representations
from openpype.modules import ModulesManager
NOT_SET = object()
class SiteSyncModel:
def __init__(self, controller):
self._controller = controller
self._sync_server_module = NOT_SET
self._sync_server_enabled = None
self._active_site = NOT_SET
self._remote_site = NOT_SET
self._active_site_provider = NOT_SET
self._remote_site_provider = NOT_SET
def reset(self):
self._sync_server_module = NOT_SET
self._sync_server_enabled = None
self._active_site = NOT_SET
self._remote_site = NOT_SET
self._active_site_provider = NOT_SET
self._remote_site_provider = NOT_SET
def is_sync_server_enabled(self):
"""Site sync is enabled.
Returns:
bool: Is enabled or not.
"""
self._cache_sync_server_module()
return self._sync_server_enabled
def get_site_provider_icons(self):
"""Icon paths per provider.
Returns:
dict[str, str]: Path by provider name.
"""
site_sync = self._get_sync_server_module()
if site_sync is None:
return {}
return site_sync.get_site_icons()
def get_sites_information(self):
return {
"active_site": self._get_active_site(),
"active_site_provider": self._get_active_site_provider(),
"remote_site": self._get_remote_site(),
"remote_site_provider": self._get_remote_site_provider()
}
def get_representations_site_progress(self, representation_ids):
"""Get progress of representations sync."""
representation_ids = set(representation_ids)
output = {
repre_id: {
"active_site": 0,
"remote_site": 0,
}
for repre_id in representation_ids
}
if not self.is_sync_server_enabled():
return output
project_name = self._controller.get_current_project_name()
site_sync = self._get_sync_server_module()
repre_docs = get_representations(project_name, representation_ids)
active_site = self._get_active_site()
remote_site = self._get_remote_site()
for repre_doc in repre_docs:
repre_output = output[repre_doc["_id"]]
result = site_sync.get_progress_for_repre(
repre_doc, active_site, remote_site
)
repre_output["active_site"] = result[active_site]
repre_output["remote_site"] = result[remote_site]
return output
def resync_representations(self, representation_ids, site_type):
"""
Args:
representation_ids (Iterable[str]): Representation ids.
site_type (Literal[active_site, remote_site]): Site type.
"""
project_name = self._controller.get_current_project_name()
site_sync = self._get_sync_server_module()
active_site = self._get_active_site()
remote_site = self._get_remote_site()
progress = self.get_representations_site_progress(
representation_ids
)
for repre_id in representation_ids:
repre_progress = progress.get(repre_id)
if not repre_progress:
continue
if site_type == "active_site":
# check opposite from added site, must be 1 or unable to sync
check_progress = repre_progress["remote_site"]
site = active_site
else:
check_progress = repre_progress["active_site"]
site = remote_site
if check_progress == 1:
site_sync.add_site(
project_name, repre_id, site, force=True
)
def _get_sync_server_module(self):
self._cache_sync_server_module()
return self._sync_server_module
def _cache_sync_server_module(self):
if self._sync_server_module is not NOT_SET:
return self._sync_server_module
manager = ModulesManager()
site_sync = manager.modules_by_name.get("sync_server")
sync_enabled = site_sync is not None and site_sync.enabled
self._sync_server_module = site_sync
self._sync_server_enabled = sync_enabled
def _get_active_site(self):
if self._active_site is NOT_SET:
self._cache_sites()
return self._active_site
def _get_remote_site(self):
if self._remote_site is NOT_SET:
self._cache_sites()
return self._remote_site
def _get_active_site_provider(self):
if self._active_site_provider is NOT_SET:
self._cache_sites()
return self._active_site_provider
def _get_remote_site_provider(self):
if self._remote_site_provider is NOT_SET:
self._cache_sites()
return self._remote_site_provider
def _cache_sites(self):
site_sync = self._get_sync_server_module()
active_site = None
remote_site = None
active_site_provider = None
remote_site_provider = None
if site_sync is not None:
project_name = self._controller.get_current_project_name()
active_site = site_sync.get_active_site(project_name)
remote_site = site_sync.get_remote_site(project_name)
active_site_provider = "studio"
remote_site_provider = "studio"
if active_site != "studio":
active_site_provider = site_sync.get_active_provider(
project_name, active_site
)
if remote_site != "studio":
remote_site_provider = site_sync.get_active_provider(
project_name, remote_site
)
self._active_site = active_site
self._remote_site = remote_site
self._active_site_provider = active_site_provider
self._remote_site_provider = remote_site_provider

View file

@ -0,0 +1,6 @@
from .dialog import SwitchAssetDialog
__all__ = (
"SwitchAssetDialog",
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,307 @@
from qtpy import QtWidgets, QtCore
import qtawesome
from openpype.tools.utils import (
PlaceholderLineEdit,
BaseClickableFrame,
set_style_property,
)
from openpype.tools.ayon_utils.widgets import FoldersWidget
NOT_SET = object()
class ClickableLineEdit(QtWidgets.QLineEdit):
"""QLineEdit capturing left mouse click.
Triggers `clicked` signal on mouse click.
"""
clicked = QtCore.Signal()
def __init__(self, *args, **kwargs):
super(ClickableLineEdit, self).__init__(*args, **kwargs)
self.setReadOnly(True)
self._mouse_pressed = False
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
self._mouse_pressed = True
event.accept()
def mouseMoveEvent(self, event):
event.accept()
def mouseReleaseEvent(self, event):
if self._mouse_pressed:
self._mouse_pressed = False
if self.rect().contains(event.pos()):
self.clicked.emit()
event.accept()
def mouseDoubleClickEvent(self, event):
event.accept()
class ControllerWrap:
def __init__(self, controller):
self._controller = controller
self._selected_folder_id = None
def emit_event(self, *args, **kwargs):
self._controller.emit_event(*args, **kwargs)
def register_event_callback(self, *args, **kwargs):
self._controller.register_event_callback(*args, **kwargs)
def get_current_project_name(self):
return self._controller.get_current_project_name()
def get_folder_items(self, *args, **kwargs):
return self._controller.get_folder_items(*args, **kwargs)
def set_selected_folder(self, folder_id):
self._selected_folder_id = folder_id
def get_selected_folder_id(self):
return self._selected_folder_id
class FoldersDialog(QtWidgets.QDialog):
"""Dialog to select asset for a context of instance."""
def __init__(self, controller, parent):
super(FoldersDialog, self).__init__(parent)
self.setWindowTitle("Select folder")
filter_input = PlaceholderLineEdit(self)
filter_input.setPlaceholderText("Filter folders..")
controller_wrap = ControllerWrap(controller)
folders_widget = FoldersWidget(controller_wrap, self)
folders_widget.set_deselectable(True)
ok_btn = QtWidgets.QPushButton("OK", self)
cancel_btn = QtWidgets.QPushButton("Cancel", self)
btns_layout = QtWidgets.QHBoxLayout()
btns_layout.addStretch(1)
btns_layout.addWidget(ok_btn)
btns_layout.addWidget(cancel_btn)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(filter_input, 0)
layout.addWidget(folders_widget, 1)
layout.addLayout(btns_layout, 0)
folders_widget.double_clicked.connect(self._on_ok_clicked)
folders_widget.refreshed.connect(self._on_folders_refresh)
filter_input.textChanged.connect(self._on_filter_change)
ok_btn.clicked.connect(self._on_ok_clicked)
cancel_btn.clicked.connect(self._on_cancel_clicked)
self._filter_input = filter_input
self._ok_btn = ok_btn
self._cancel_btn = cancel_btn
self._folders_widget = folders_widget
self._controller_wrap = controller_wrap
# Set selected folder only when user confirms the dialog
self._selected_folder_id = None
self._selected_folder_label = None
self._folder_id_to_select = NOT_SET
self._first_show = True
self._default_height = 500
def showEvent(self, event):
"""Refresh asset model on show."""
super(FoldersDialog, self).showEvent(event)
if self._first_show:
self._first_show = False
self._on_first_show()
def refresh(self):
project_name = self._controller_wrap.get_current_project_name()
self._folders_widget.set_project_name(project_name)
def _on_first_show(self):
center = self.rect().center()
size = self.size()
size.setHeight(self._default_height)
self.resize(size)
new_pos = self.mapToGlobal(center)
new_pos.setX(new_pos.x() - int(self.width() / 2))
new_pos.setY(new_pos.y() - int(self.height() / 2))
self.move(new_pos)
def _on_folders_refresh(self):
if self._folder_id_to_select is NOT_SET:
return
self._folders_widget.set_selected_folder(self._folder_id_to_select)
self._folder_id_to_select = NOT_SET
def _on_filter_change(self, text):
"""Trigger change of filter of folders."""
self._folders_widget.set_name_filter(text)
def _on_cancel_clicked(self):
self.done(0)
def _on_ok_clicked(self):
self._selected_folder_id = (
self._folders_widget.get_selected_folder_id()
)
self._selected_folder_label = (
self._folders_widget.get_selected_folder_label()
)
self.done(1)
def set_selected_folder(self, folder_id):
"""Change preselected folder before showing the dialog.
This also resets model and clean filter.
"""
if (
self._folders_widget.is_refreshing
or self._folders_widget.get_project_name() is None
):
self._folder_id_to_select = folder_id
else:
self._folders_widget.set_selected_folder(folder_id)
def get_selected_folder_id(self):
"""Get selected folder id.
Returns:
Union[str, None]: Selected folder id or None if nothing
is selected.
"""
return self._selected_folder_id
def get_selected_folder_label(self):
return self._selected_folder_label
class FoldersField(BaseClickableFrame):
"""Field where asset name of selected instance/s is showed.
Click on the field will trigger `FoldersDialog`.
"""
value_changed = QtCore.Signal()
def __init__(self, controller, parent):
super(FoldersField, self).__init__(parent)
self.setObjectName("AssetNameInputWidget")
# Don't use 'self' for parent!
# - this widget has specific styles
dialog = FoldersDialog(controller, parent)
name_input = ClickableLineEdit(self)
name_input.setObjectName("AssetNameInput")
icon = qtawesome.icon("fa.window-maximize", color="white")
icon_btn = QtWidgets.QPushButton(self)
icon_btn.setIcon(icon)
icon_btn.setObjectName("AssetNameInputButton")
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(name_input, 1)
layout.addWidget(icon_btn, 0)
# Make sure all widgets are vertically extended to highest widget
for widget in (
name_input,
icon_btn
):
w_size_policy = widget.sizePolicy()
w_size_policy.setVerticalPolicy(
QtWidgets.QSizePolicy.MinimumExpanding)
widget.setSizePolicy(w_size_policy)
size_policy = self.sizePolicy()
size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Maximum)
self.setSizePolicy(size_policy)
name_input.clicked.connect(self._mouse_release_callback)
icon_btn.clicked.connect(self._mouse_release_callback)
dialog.finished.connect(self._on_dialog_finish)
self._controller = controller
self._dialog = dialog
self._name_input = name_input
self._icon_btn = icon_btn
self._selected_folder_id = None
self._selected_folder_label = None
self._selected_items = []
self._is_valid = True
def refresh(self):
self._dialog.refresh()
def is_valid(self):
"""Is asset valid."""
return self._is_valid
def get_selected_folder_id(self):
"""Selected asset names."""
return self._selected_folder_id
def get_selected_folder_label(self):
return self._selected_folder_label
def set_text(self, text):
"""Set text in text field.
Does not change selected items (assets).
"""
self._name_input.setText(text)
def set_valid(self, is_valid):
state = ""
if not is_valid:
state = "invalid"
self._set_state_property(state)
def set_selected_item(self, folder_id=None, folder_label=None):
"""Set folder for selection.
Args:
folder_id (Optional[str]): Folder id to select.
folder_label (Optional[str]): Folder label.
"""
self._selected_folder_id = folder_id
if not folder_id:
folder_label = None
elif folder_id and not folder_label:
folder_label = self._controller.get_folder_label(folder_id)
self._selected_folder_label = folder_label
self.set_text(folder_label if folder_label else "<folder>")
def _on_dialog_finish(self, result):
if not result:
return
folder_id = self._dialog.get_selected_folder_id()
folder_label = self._dialog.get_selected_folder_label()
self.set_selected_item(folder_id, folder_label)
self.value_changed.emit()
def _mouse_release_callback(self):
self._dialog.set_selected_folder(self._selected_folder_id)
self._dialog.open()
def _set_state_property(self, state):
set_style_property(self, "state", state)
set_style_property(self._name_input, "state", state)
set_style_property(self._icon_btn, "state", state)

View file

@ -0,0 +1,94 @@
from qtpy import QtWidgets, QtCore
from openpype import style
class ButtonWithMenu(QtWidgets.QToolButton):
def __init__(self, parent=None):
super(ButtonWithMenu, self).__init__(parent)
self.setObjectName("ButtonWithMenu")
self.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup)
menu = QtWidgets.QMenu(self)
self.setMenu(menu)
self._menu = menu
self._actions = []
def menu(self):
return self._menu
def clear_actions(self):
if self._menu is not None:
self._menu.clear()
self._actions = []
def add_action(self, action):
self._actions.append(action)
self._menu.addAction(action)
def _on_action_trigger(self):
action = self.sender()
if action not in self._actions:
return
action.trigger()
class SearchComboBox(QtWidgets.QComboBox):
"""Searchable ComboBox with empty placeholder value as first value"""
def __init__(self, parent):
super(SearchComboBox, self).__init__(parent)
self.setEditable(True)
self.setInsertPolicy(QtWidgets.QComboBox.NoInsert)
combobox_delegate = QtWidgets.QStyledItemDelegate(self)
self.setItemDelegate(combobox_delegate)
completer = self.completer()
completer.setCompletionMode(
QtWidgets.QCompleter.PopupCompletion
)
completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive)
completer_view = completer.popup()
completer_view.setObjectName("CompleterView")
completer_delegate = QtWidgets.QStyledItemDelegate(completer_view)
completer_view.setItemDelegate(completer_delegate)
completer_view.setStyleSheet(style.load_stylesheet())
self._combobox_delegate = combobox_delegate
self._completer_delegate = completer_delegate
self._completer = completer
def set_placeholder(self, placeholder):
self.lineEdit().setPlaceholderText(placeholder)
def populate(self, items):
self.clear()
self.addItems([""]) # ensure first item is placeholder
self.addItems(items)
def get_valid_value(self):
"""Return the current text if it's a valid value else None
Note: The empty placeholder value is valid and returns as ""
"""
text = self.currentText()
lookup = set(self.itemText(i) for i in range(self.count()))
if text not in lookup:
return None
return text or None
def set_valid_value(self, value):
"""Try to locate 'value' and pre-select it in dropdown."""
index = self.findText(value)
if index > -1:
self.setCurrentIndex(index)

View file

@ -0,0 +1,825 @@
import uuid
import collections
import logging
import itertools
from functools import partial
from qtpy import QtWidgets, QtCore
import qtawesome
from openpype.client import (
get_version_by_id,
get_versions,
get_hero_versions,
get_representation_by_id,
get_representations,
)
from openpype import style
from openpype.pipeline import (
HeroVersionType,
update_container,
remove_container,
discover_inventory_actions,
)
from openpype.tools.utils.lib import (
iter_model_rows,
format_version
)
from .switch_dialog import SwitchAssetDialog
from .model import InventoryModel
DEFAULT_COLOR = "#fb9c15"
log = logging.getLogger("SceneInventory")
class SceneInventoryView(QtWidgets.QTreeView):
data_changed = QtCore.Signal()
hierarchy_view_changed = QtCore.Signal(bool)
def __init__(self, controller, parent):
super(SceneInventoryView, self).__init__(parent=parent)
# view settings
self.setIndentation(12)
self.setAlternatingRowColors(True)
self.setSortingEnabled(True)
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self._show_right_mouse_menu)
self._hierarchy_view = False
self._selected = None
self._controller = controller
def _set_hierarchy_view(self, enabled):
if enabled == self._hierarchy_view:
return
self._hierarchy_view = enabled
self.hierarchy_view_changed.emit(enabled)
def _enter_hierarchy(self, items):
self._selected = set(i["objectName"] for i in items)
self._set_hierarchy_view(True)
self.data_changed.emit()
self.expandToDepth(1)
self.setStyleSheet("""
QTreeView {
border-color: #fb9c15;
}
""")
def _leave_hierarchy(self):
self._set_hierarchy_view(False)
self.data_changed.emit()
self.setStyleSheet("QTreeView {}")
def _build_item_menu_for_selection(self, items, menu):
# Exclude items that are "NOT FOUND" since setting versions, updating
# and removal won't work for those items.
items = [item for item in items if not item.get("isNotFound")]
if not items:
return
# An item might not have a representation, for example when an item
# is listed as "NOT FOUND"
repre_ids = set()
for item in items:
repre_id = item["representation"]
try:
uuid.UUID(repre_id)
repre_ids.add(repre_id)
except ValueError:
pass
project_name = self._controller.get_current_project_name()
repre_docs = get_representations(
project_name, representation_ids=repre_ids, fields=["parent"]
)
version_ids = {
repre_doc["parent"]
for repre_doc in repre_docs
}
loaded_versions = get_versions(
project_name, version_ids=version_ids, hero=True
)
loaded_hero_versions = []
versions_by_parent_id = collections.defaultdict(list)
subset_ids = set()
for version in loaded_versions:
if version["type"] == "hero_version":
loaded_hero_versions.append(version)
else:
parent_id = version["parent"]
versions_by_parent_id[parent_id].append(version)
subset_ids.add(parent_id)
all_versions = get_versions(
project_name, subset_ids=subset_ids, hero=True
)
hero_versions = []
versions = []
for version in all_versions:
if version["type"] == "hero_version":
hero_versions.append(version)
else:
versions.append(version)
has_loaded_hero_versions = len(loaded_hero_versions) > 0
has_available_hero_version = len(hero_versions) > 0
has_outdated = False
for version in versions:
parent_id = version["parent"]
current_versions = versions_by_parent_id[parent_id]
for current_version in current_versions:
if current_version["name"] < version["name"]:
has_outdated = True
break
if has_outdated:
break
switch_to_versioned = None
if has_loaded_hero_versions:
def _on_switch_to_versioned(items):
repre_ids = {
item["representation"]
for item in items
}
repre_docs = get_representations(
project_name,
representation_ids=repre_ids,
fields=["parent"]
)
version_ids = set()
version_id_by_repre_id = {}
for repre_doc in repre_docs:
version_id = repre_doc["parent"]
repre_id = str(repre_doc["_id"])
version_id_by_repre_id[repre_id] = version_id
version_ids.add(version_id)
hero_versions = get_hero_versions(
project_name,
version_ids=version_ids,
fields=["version_id"]
)
hero_src_version_ids = set()
for hero_version in hero_versions:
version_id = hero_version["version_id"]
hero_src_version_ids.add(version_id)
hero_version_id = hero_version["_id"]
for _repre_id, current_version_id in (
version_id_by_repre_id.items()
):
if current_version_id == hero_version_id:
version_id_by_repre_id[_repre_id] = version_id
version_docs = get_versions(
project_name,
version_ids=hero_src_version_ids,
fields=["name"]
)
version_name_by_id = {}
for version_doc in version_docs:
version_name_by_id[version_doc["_id"]] = \
version_doc["name"]
# Specify version per item to update to
update_items = []
update_versions = []
for item in items:
repre_id = item["representation"]
version_id = version_id_by_repre_id.get(repre_id)
version_name = version_name_by_id.get(version_id)
if version_name is not None:
update_items.append(item)
update_versions.append(version_name)
self._update_containers(update_items, update_versions)
update_icon = qtawesome.icon(
"fa.asterisk",
color=DEFAULT_COLOR
)
switch_to_versioned = QtWidgets.QAction(
update_icon,
"Switch to versioned",
menu
)
switch_to_versioned.triggered.connect(
lambda: _on_switch_to_versioned(items)
)
update_to_latest_action = None
if has_outdated or has_loaded_hero_versions:
update_icon = qtawesome.icon(
"fa.angle-double-up",
color=DEFAULT_COLOR
)
update_to_latest_action = QtWidgets.QAction(
update_icon,
"Update to latest",
menu
)
update_to_latest_action.triggered.connect(
lambda: self._update_containers(items, version=-1)
)
change_to_hero = None
if has_available_hero_version:
# TODO change icon
change_icon = qtawesome.icon(
"fa.asterisk",
color="#00b359"
)
change_to_hero = QtWidgets.QAction(
change_icon,
"Change to hero",
menu
)
change_to_hero.triggered.connect(
lambda: self._update_containers(items,
version=HeroVersionType(-1))
)
# set version
set_version_icon = qtawesome.icon("fa.hashtag", color=DEFAULT_COLOR)
set_version_action = QtWidgets.QAction(
set_version_icon,
"Set version",
menu
)
set_version_action.triggered.connect(
lambda: self._show_version_dialog(items))
# switch folder
switch_folder_icon = qtawesome.icon("fa.sitemap", color=DEFAULT_COLOR)
switch_folder_action = QtWidgets.QAction(
switch_folder_icon,
"Switch Folder",
menu
)
switch_folder_action.triggered.connect(
lambda: self._show_switch_dialog(items))
# remove
remove_icon = qtawesome.icon("fa.remove", color=DEFAULT_COLOR)
remove_action = QtWidgets.QAction(remove_icon, "Remove items", menu)
remove_action.triggered.connect(
lambda: self._show_remove_warning_dialog(items))
# add the actions
if switch_to_versioned:
menu.addAction(switch_to_versioned)
if update_to_latest_action:
menu.addAction(update_to_latest_action)
if change_to_hero:
menu.addAction(change_to_hero)
menu.addAction(set_version_action)
menu.addAction(switch_folder_action)
menu.addSeparator()
menu.addAction(remove_action)
self._handle_sync_server(menu, repre_ids)
def _handle_sync_server(self, menu, repre_ids):
"""Adds actions for download/upload when SyncServer is enabled
Args:
menu (OptionMenu)
repre_ids (list) of object_ids
Returns:
(OptionMenu)
"""
if not self._controller.is_sync_server_enabled():
return
menu.addSeparator()
download_icon = qtawesome.icon("fa.download", color=DEFAULT_COLOR)
download_active_action = QtWidgets.QAction(
download_icon,
"Download",
menu
)
download_active_action.triggered.connect(
lambda: self._add_sites(repre_ids, "active_site"))
upload_icon = qtawesome.icon("fa.upload", color=DEFAULT_COLOR)
upload_remote_action = QtWidgets.QAction(
upload_icon,
"Upload",
menu
)
upload_remote_action.triggered.connect(
lambda: self._add_sites(repre_ids, "remote_site"))
menu.addAction(download_active_action)
menu.addAction(upload_remote_action)
def _add_sites(self, repre_ids, site_type):
"""(Re)sync all 'repre_ids' to specific site.
It checks if opposite site has fully available content to limit
accidents. (ReSync active when no remote >> losing active content)
Args:
repre_ids (list)
site_type (Literal[active_site, remote_site]): Site type.
"""
self._controller.resync_representations(repre_ids, site_type)
self.data_changed.emit()
def _build_item_menu(self, items=None):
"""Create menu for the selected items"""
if not items:
items = []
menu = QtWidgets.QMenu(self)
# add the actions
self._build_item_menu_for_selection(items, menu)
# These two actions should be able to work without selection
# expand all items
expandall_action = QtWidgets.QAction(menu, text="Expand all items")
expandall_action.triggered.connect(self.expandAll)
# collapse all items
collapse_action = QtWidgets.QAction(menu, text="Collapse all items")
collapse_action.triggered.connect(self.collapseAll)
menu.addAction(expandall_action)
menu.addAction(collapse_action)
custom_actions = self._get_custom_actions(containers=items)
if custom_actions:
submenu = QtWidgets.QMenu("Actions", self)
for action in custom_actions:
color = action.color or DEFAULT_COLOR
icon = qtawesome.icon("fa.%s" % action.icon, color=color)
action_item = QtWidgets.QAction(icon, action.label, submenu)
action_item.triggered.connect(
partial(self._process_custom_action, action, items))
submenu.addAction(action_item)
menu.addMenu(submenu)
# go back to flat view
back_to_flat_action = None
if self._hierarchy_view:
back_to_flat_icon = qtawesome.icon("fa.list", color=DEFAULT_COLOR)
back_to_flat_action = QtWidgets.QAction(
back_to_flat_icon,
"Back to Full-View",
menu
)
back_to_flat_action.triggered.connect(self._leave_hierarchy)
# send items to hierarchy view
enter_hierarchy_icon = qtawesome.icon("fa.indent", color="#d8d8d8")
enter_hierarchy_action = QtWidgets.QAction(
enter_hierarchy_icon,
"Cherry-Pick (Hierarchy)",
menu
)
enter_hierarchy_action.triggered.connect(
lambda: self._enter_hierarchy(items))
if items:
menu.addAction(enter_hierarchy_action)
if back_to_flat_action is not None:
menu.addAction(back_to_flat_action)
return menu
def _get_custom_actions(self, containers):
"""Get the registered Inventory Actions
Args:
containers(list): collection of containers
Returns:
list: collection of filter and initialized actions
"""
def sorter(Plugin):
"""Sort based on order attribute of the plugin"""
return Plugin.order
# Fedd an empty dict if no selection, this will ensure the compat
# lookup always work, so plugin can interact with Scene Inventory
# reversely.
containers = containers or [dict()]
# Check which action will be available in the menu
Plugins = discover_inventory_actions()
compatible = [p() for p in Plugins if
any(p.is_compatible(c) for c in containers)]
return sorted(compatible, key=sorter)
def _process_custom_action(self, action, containers):
"""Run action and if results are returned positive update the view
If the result is list or dict, will select view items by the result.
Args:
action (InventoryAction): Inventory Action instance
containers (list): Data of currently selected items
Returns:
None
"""
result = action.process(containers)
if result:
self.data_changed.emit()
if isinstance(result, (list, set)):
self._select_items_by_action(result)
if isinstance(result, dict):
self._select_items_by_action(
result["objectNames"], result["options"]
)
def _select_items_by_action(self, object_names, options=None):
"""Select view items by the result of action
Args:
object_names (list or set): A list/set of container object name
options (dict): GUI operation options.
Returns:
None
"""
options = options or dict()
if options.get("clear", True):
self.clearSelection()
object_names = set(object_names)
if (
self._hierarchy_view
and not self._selected.issuperset(object_names)
):
# If any container not in current cherry-picked view, update
# view before selecting them.
self._selected.update(object_names)
self.data_changed.emit()
model = self.model()
selection_model = self.selectionModel()
select_mode = {
"select": QtCore.QItemSelectionModel.Select,
"deselect": QtCore.QItemSelectionModel.Deselect,
"toggle": QtCore.QItemSelectionModel.Toggle,
}[options.get("mode", "select")]
for index in iter_model_rows(model, 0):
item = index.data(InventoryModel.ItemRole)
if item.get("isGroupNode"):
continue
name = item.get("objectName")
if name in object_names:
self.scrollTo(index) # Ensure item is visible
flags = select_mode | QtCore.QItemSelectionModel.Rows
selection_model.select(index, flags)
object_names.remove(name)
if len(object_names) == 0:
break
def _show_right_mouse_menu(self, pos):
"""Display the menu when at the position of the item clicked"""
globalpos = self.viewport().mapToGlobal(pos)
if not self.selectionModel().hasSelection():
print("No selection")
# Build menu without selection, feed an empty list
menu = self._build_item_menu()
menu.exec_(globalpos)
return
active = self.currentIndex() # index under mouse
active = active.sibling(active.row(), 0) # get first column
# move index under mouse
indices = self.get_indices()
if active in indices:
indices.remove(active)
indices.append(active)
# Extend to the sub-items
all_indices = self._extend_to_children(indices)
items = [dict(i.data(InventoryModel.ItemRole)) for i in all_indices
if i.parent().isValid()]
if self._hierarchy_view:
# Ensure no group item
items = [n for n in items if not n.get("isGroupNode")]
menu = self._build_item_menu(items)
menu.exec_(globalpos)
def get_indices(self):
"""Get the selected rows"""
selection_model = self.selectionModel()
return selection_model.selectedRows()
def _extend_to_children(self, indices):
"""Extend the indices to the children indices.
Top-level indices are extended to its children indices. Sub-items
are kept as is.
Args:
indices (list): The indices to extend.
Returns:
list: The children indices
"""
def get_children(i):
model = i.model()
rows = model.rowCount(parent=i)
for row in range(rows):
child = model.index(row, 0, parent=i)
yield child
subitems = set()
for i in indices:
valid_parent = i.parent().isValid()
if valid_parent and i not in subitems:
subitems.add(i)
if self._hierarchy_view:
# Assume this is a group item
for child in get_children(i):
subitems.add(child)
else:
# is top level item
for child in get_children(i):
subitems.add(child)
return list(subitems)
def _show_version_dialog(self, items):
"""Create a dialog with the available versions for the selected file
Args:
items (list): list of items to run the "set_version" for
Returns:
None
"""
active = items[-1]
project_name = self._controller.get_current_project_name()
# Get available versions for active representation
repre_doc = get_representation_by_id(
project_name,
active["representation"],
fields=["parent"]
)
repre_version_doc = get_version_by_id(
project_name,
repre_doc["parent"],
fields=["parent"]
)
version_docs = list(get_versions(
project_name,
subset_ids=[repre_version_doc["parent"]],
hero=True
))
hero_version = None
standard_versions = []
for version_doc in version_docs:
if version_doc["type"] == "hero_version":
hero_version = version_doc
else:
standard_versions.append(version_doc)
versions = list(reversed(
sorted(standard_versions, key=lambda item: item["name"])
))
if hero_version:
_version_id = hero_version["version_id"]
for _version in versions:
if _version["_id"] != _version_id:
continue
hero_version["name"] = HeroVersionType(
_version["name"]
)
hero_version["data"] = _version["data"]
break
# Get index among the listed versions
current_item = None
current_version = active["version"]
if isinstance(current_version, HeroVersionType):
current_item = hero_version
else:
for version in versions:
if version["name"] == current_version:
current_item = version
break
all_versions = []
if hero_version:
all_versions.append(hero_version)
all_versions.extend(versions)
if current_item:
index = all_versions.index(current_item)
else:
index = 0
versions_by_label = dict()
labels = []
for version in all_versions:
is_hero = version["type"] == "hero_version"
label = format_version(version["name"], is_hero)
labels.append(label)
versions_by_label[label] = version["name"]
label, state = QtWidgets.QInputDialog.getItem(
self,
"Set version..",
"Set version number to",
labels,
current=index,
editable=False
)
if not state:
return
if label:
version = versions_by_label[label]
self._update_containers(items, version)
def _show_switch_dialog(self, items):
"""Display Switch dialog"""
dialog = SwitchAssetDialog(self._controller, self, items)
dialog.switched.connect(self.data_changed.emit)
dialog.show()
def _show_remove_warning_dialog(self, items):
"""Prompt a dialog to inform the user the action will remove items"""
accept = QtWidgets.QMessageBox.Ok
buttons = accept | QtWidgets.QMessageBox.Cancel
state = QtWidgets.QMessageBox.question(
self,
"Are you sure?",
"Are you sure you want to remove {} item(s)".format(len(items)),
buttons=buttons,
defaultButton=accept
)
if state != accept:
return
for item in items:
remove_container(item)
self.data_changed.emit()
def _show_version_error_dialog(self, version, items):
"""Shows QMessageBox when version switch doesn't work
Args:
version: str or int or None
"""
if version == -1:
version_str = "latest"
elif isinstance(version, HeroVersionType):
version_str = "hero"
elif isinstance(version, int):
version_str = "v{:03d}".format(version)
else:
version_str = version
dialog = QtWidgets.QMessageBox(self)
dialog.setIcon(QtWidgets.QMessageBox.Warning)
dialog.setStyleSheet(style.load_stylesheet())
dialog.setWindowTitle("Update failed")
switch_btn = dialog.addButton(
"Switch Folder",
QtWidgets.QMessageBox.ActionRole
)
switch_btn.clicked.connect(lambda: self._show_switch_dialog(items))
dialog.addButton(QtWidgets.QMessageBox.Cancel)
msg = (
"Version update to '{}' failed as representation doesn't exist."
"\n\nPlease update to version with a valid representation"
" OR \n use 'Switch Folder' button to change folder."
).format(version_str)
dialog.setText(msg)
dialog.exec_()
def update_all(self):
"""Update all items that are currently 'outdated' in the view"""
# Get the source model through the proxy model
model = self.model().sourceModel()
# Get all items from outdated groups
outdated_items = []
for index in iter_model_rows(model,
column=0,
include_root=False):
item = index.data(model.ItemRole)
if not item.get("isGroupNode"):
continue
# Only the group nodes contain the "highest_version" data and as
# such we find only the groups and take its children.
if not model.outdated(item):
continue
# Collect all children which we want to update
children = item.children()
outdated_items.extend(children)
if not outdated_items:
log.info("Nothing to update.")
return
# Trigger update to latest
self._update_containers(outdated_items, version=-1)
def _update_containers(self, items, version):
"""Helper to update items to given version (or version per item)
If at least one item is specified this will always try to refresh
the inventory even if errors occurred on any of the items.
Arguments:
items (list): Items to update
version (int or list): Version to set to.
This can be a list specifying a version for each item.
Like `update_container` version -1 sets the latest version
and HeroTypeVersion instances set the hero version.
"""
if isinstance(version, (list, tuple)):
# We allow a unique version to be specified per item. In that case
# the length must match with the items
assert len(items) == len(version), (
"Number of items mismatches number of versions: "
"{} items - {} versions".format(len(items), len(version))
)
versions = version
else:
# Repeat the same version infinitely
versions = itertools.repeat(version)
# Trigger update to latest
try:
for item, item_version in zip(items, versions):
try:
update_container(item, item_version)
except AssertionError:
self._show_version_error_dialog(item_version, [item])
log.warning("Update failed", exc_info=True)
finally:
# Always update the scene inventory view, even if errors occurred
self.data_changed.emit()

View file

@ -0,0 +1,200 @@
from qtpy import QtWidgets, QtCore, QtGui
import qtawesome
from openpype import style, resources
from openpype.tools.utils.delegates import VersionDelegate
from openpype.tools.utils.lib import (
preserve_expanded_rows,
preserve_selection,
)
from openpype.tools.ayon_sceneinventory import SceneInventoryController
from .model import (
InventoryModel,
FilterProxyModel
)
from .view import SceneInventoryView
class ControllerVersionDelegate(VersionDelegate):
"""Version delegate that uses controller to get project.
Original VersionDelegate is using 'AvalonMongoDB' object instead. Don't
worry about the variable name, object is stored to '_dbcon' attribute.
"""
def get_project_name(self):
self._dbcon.get_current_project_name()
class SceneInventoryWindow(QtWidgets.QDialog):
"""Scene Inventory window"""
def __init__(self, controller=None, parent=None):
super(SceneInventoryWindow, self).__init__(parent)
if controller is None:
controller = SceneInventoryController()
project_name = controller.get_current_project_name()
icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
self.setWindowIcon(icon)
self.setWindowTitle("Scene Inventory - {}".format(project_name))
self.setObjectName("SceneInventory")
self.resize(1100, 480)
# region control
filter_label = QtWidgets.QLabel("Search", self)
text_filter = QtWidgets.QLineEdit(self)
outdated_only_checkbox = QtWidgets.QCheckBox(
"Filter to outdated", self
)
outdated_only_checkbox.setToolTip("Show outdated files only")
outdated_only_checkbox.setChecked(False)
icon = qtawesome.icon("fa.arrow-up", color="white")
update_all_button = QtWidgets.QPushButton(self)
update_all_button.setToolTip("Update all outdated to latest version")
update_all_button.setIcon(icon)
icon = qtawesome.icon("fa.refresh", color="white")
refresh_button = QtWidgets.QPushButton(self)
refresh_button.setToolTip("Refresh")
refresh_button.setIcon(icon)
control_layout = QtWidgets.QHBoxLayout()
control_layout.addWidget(filter_label)
control_layout.addWidget(text_filter)
control_layout.addWidget(outdated_only_checkbox)
control_layout.addWidget(update_all_button)
control_layout.addWidget(refresh_button)
model = InventoryModel(controller)
proxy = FilterProxyModel()
proxy.setSourceModel(model)
proxy.setDynamicSortFilter(True)
proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
view = SceneInventoryView(controller, self)
view.setModel(proxy)
sync_enabled = controller.is_sync_server_enabled()
view.setColumnHidden(model.active_site_col, not sync_enabled)
view.setColumnHidden(model.remote_site_col, not sync_enabled)
# set some nice default widths for the view
view.setColumnWidth(0, 250) # name
view.setColumnWidth(1, 55) # version
view.setColumnWidth(2, 55) # count
view.setColumnWidth(3, 150) # family
view.setColumnWidth(4, 120) # group
view.setColumnWidth(5, 150) # loader
# apply delegates
version_delegate = ControllerVersionDelegate(controller, self)
column = model.Columns.index("version")
view.setItemDelegateForColumn(column, version_delegate)
layout = QtWidgets.QVBoxLayout(self)
layout.addLayout(control_layout)
layout.addWidget(view)
show_timer = QtCore.QTimer()
show_timer.setInterval(0)
show_timer.setSingleShot(False)
# signals
show_timer.timeout.connect(self._on_show_timer)
text_filter.textChanged.connect(self._on_text_filter_change)
outdated_only_checkbox.stateChanged.connect(
self._on_outdated_state_change
)
view.hierarchy_view_changed.connect(
self._on_hierarchy_view_change
)
view.data_changed.connect(self._on_refresh_request)
refresh_button.clicked.connect(self._on_refresh_request)
update_all_button.clicked.connect(self._on_update_all)
self._show_timer = show_timer
self._show_counter = 0
self._controller = controller
self._update_all_button = update_all_button
self._outdated_only_checkbox = outdated_only_checkbox
self._view = view
self._model = model
self._proxy = proxy
self._version_delegate = version_delegate
self._first_show = True
self._first_refresh = True
def showEvent(self, event):
super(SceneInventoryWindow, self).showEvent(event)
if self._first_show:
self._first_show = False
self.setStyleSheet(style.load_stylesheet())
self._show_counter = 0
self._show_timer.start()
def keyPressEvent(self, event):
"""Custom keyPressEvent.
Override keyPressEvent to do nothing so that Maya's panels won't
take focus when pressing "SHIFT" whilst mouse is over viewport or
outliner. This way users don't accidentally perform Maya commands
whilst trying to name an instance.
"""
def _on_refresh_request(self):
"""Signal callback to trigger 'refresh' without any arguments."""
self.refresh()
def refresh(self, containers=None):
self._first_refresh = False
self._controller.reset()
with preserve_expanded_rows(
tree_view=self._view,
role=self._model.UniqueRole
):
with preserve_selection(
tree_view=self._view,
role=self._model.UniqueRole,
current_index=False
):
kwargs = {"containers": containers}
# TODO do not touch view's inner attribute
if self._view._hierarchy_view:
kwargs["selected"] = self._view._selected
self._model.refresh(**kwargs)
def _on_show_timer(self):
if self._show_counter < 3:
self._show_counter += 1
return
self._show_timer.stop()
self.refresh()
def _on_hierarchy_view_change(self, enabled):
self._proxy.set_hierarchy_view(enabled)
self._model.set_hierarchy_view(enabled)
def _on_text_filter_change(self, text_filter):
if hasattr(self._proxy, "setFilterRegExp"):
self._proxy.setFilterRegExp(text_filter)
else:
self._proxy.setFilterRegularExpression(text_filter)
def _on_outdated_state_change(self):
self._proxy.set_filter_outdated(
self._outdated_only_checkbox.isChecked()
)
def _on_update_all(self):
self._view.update_all()

View file

@ -29,16 +29,21 @@ class FolderItem:
parent_id (Union[str, None]): Parent folder id. If 'None' then project
is parent.
name (str): Name of folder.
path (str): Folder path.
folder_type (str): Type of folder.
label (Union[str, None]): Folder label.
icon (Union[dict[str, Any], None]): Icon definition.
"""
def __init__(
self, entity_id, parent_id, name, label, icon
self, entity_id, parent_id, name, path, folder_type, label, icon
):
self.entity_id = entity_id
self.parent_id = parent_id
self.name = name
self.path = path
self.folder_type = folder_type
self.label = label or name
if not icon:
icon = {
"type": "awesome-font",
@ -46,7 +51,6 @@ class FolderItem:
"color": get_default_entity_icon_color()
}
self.icon = icon
self.label = label or name
def to_data(self):
"""Converts folder item to data.
@ -59,6 +63,8 @@ class FolderItem:
"entity_id": self.entity_id,
"parent_id": self.parent_id,
"name": self.name,
"path": self.path,
"folder_type": self.folder_type,
"label": self.label,
"icon": self.icon,
}
@ -90,8 +96,7 @@ class TaskItem:
name (str): Name of task.
task_type (str): Type of task.
parent_id (str): Parent folder id.
icon_name (str): Name of icon from font awesome.
icon_color (str): Hex color string that will be used for icon.
icon (Union[dict[str, Any], None]): Icon definitions.
"""
def __init__(
@ -183,12 +188,31 @@ def _get_task_items_from_tasks(tasks):
def _get_folder_item_from_hierarchy_item(item):
name = item["name"]
path_parts = list(item["parents"])
path_parts.append(name)
return FolderItem(
item["id"],
item["parentId"],
item["name"],
name,
"/".join(path_parts),
item["folderType"],
item["label"],
None
None,
)
def _get_folder_item_from_entity(entity):
name = entity["name"]
return FolderItem(
entity["id"],
entity["parentId"],
name,
entity["path"],
entity["folderType"],
entity["label"] or name,
None,
)
@ -223,13 +247,84 @@ class HierarchyModel(object):
self._tasks_by_id.reset()
def refresh_project(self, project_name):
"""Force to refresh folder items for a project.
Args:
project_name (str): Name of project to refresh.
"""
self._refresh_folders_cache(project_name)
def get_folder_items(self, project_name, sender):
"""Get folder items by project name.
The folders are cached per project name. If the cache is not valid
then the folders are queried from server.
Args:
project_name (str): Name of project where to look for folders.
sender (Union[str, None]): Who requested the folder ids.
Returns:
dict[str, FolderItem]: Folder items by id.
"""
if not self._folders_items[project_name].is_valid:
self._refresh_folders_cache(project_name, sender)
return self._folders_items[project_name].get_data()
def get_folder_items_by_id(self, project_name, folder_ids):
"""Get folder items by ids.
This function will query folders if they are not in cache. But the
queried items are not added to cache back.
Args:
project_name (str): Name of project where to look for folders.
folder_ids (Iterable[str]): Folder ids.
Returns:
dict[str, Union[FolderItem, None]]: Folder items by id.
"""
folder_ids = set(folder_ids)
if self._folders_items[project_name].is_valid:
cache_data = self._folders_items[project_name].get_data()
return {
folder_id: cache_data.get(folder_id)
for folder_id in folder_ids
}
folders = ayon_api.get_folders(
project_name,
folder_ids=folder_ids,
fields=["id", "name", "label", "parentId", "path", "folderType"]
)
# Make sure all folder ids are in output
output = {folder_id: None for folder_id in folder_ids}
output.update({
folder["id"]: _get_folder_item_from_entity(folder)
for folder in folders
})
return output
def get_folder_item(self, project_name, folder_id):
"""Get folder items by id.
This function will query folder if they is not in cache. But the
queried items are not added to cache back.
Args:
project_name (str): Name of project where to look for folders.
folder_id (str): Folder id.
Returns:
Union[FolderItem, None]: Folder item.
"""
items = self.get_folder_items_by_id(
project_name, [folder_id]
)
return items.get(folder_id)
def get_task_items(self, project_name, folder_id, sender):
if not project_name or not folder_id:
return []

View file

@ -4,14 +4,16 @@ from qtpy import QtWidgets, QtGui, QtCore
from openpype.tools.utils import (
RecursiveSortFilterProxyModel,
DeselectableTreeView,
TreeView,
)
from .utils import RefreshThread, get_qt_icon
FOLDERS_MODEL_SENDER_NAME = "qt_folders_model"
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
ITEM_NAME_ROLE = QtCore.Qt.UserRole + 2
FOLDER_ID_ROLE = QtCore.Qt.UserRole + 1
FOLDER_NAME_ROLE = QtCore.Qt.UserRole + 2
FOLDER_PATH_ROLE = QtCore.Qt.UserRole + 3
FOLDER_TYPE_ROLE = QtCore.Qt.UserRole + 4
class FoldersModel(QtGui.QStandardItemModel):
@ -84,6 +86,15 @@ class FoldersModel(QtGui.QStandardItemModel):
return QtCore.QModelIndex()
return self.indexFromItem(item)
def get_project_name(self):
"""Project name which model currently use.
Returns:
Union[str, None]: Currently used project name.
"""
return self._last_project_name
def set_project_name(self, project_name):
"""Refresh folders items.
@ -151,12 +162,13 @@ class FoldersModel(QtGui.QStandardItemModel):
"""
icon = get_qt_icon(folder_item.icon)
item.setData(folder_item.entity_id, ITEM_ID_ROLE)
item.setData(folder_item.name, ITEM_NAME_ROLE)
item.setData(folder_item.entity_id, FOLDER_ID_ROLE)
item.setData(folder_item.name, FOLDER_NAME_ROLE)
item.setData(folder_item.path, FOLDER_PATH_ROLE)
item.setData(folder_item.folder_type, FOLDER_TYPE_ROLE)
item.setData(folder_item.label, QtCore.Qt.DisplayRole)
item.setData(icon, QtCore.Qt.DecorationRole)
def _fill_items(self, folder_items_by_id):
if not folder_items_by_id:
if folder_items_by_id is not None:
@ -193,7 +205,7 @@ class FoldersModel(QtGui.QStandardItemModel):
folder_ids_to_add = set(folder_items)
for row_idx in reversed(range(parent_item.rowCount())):
child_item = parent_item.child(row_idx)
child_id = child_item.data(ITEM_ID_ROLE)
child_id = child_item.data(FOLDER_ID_ROLE)
if child_id in ids_to_remove:
removed_items.append(parent_item.takeRow(row_idx))
else:
@ -259,10 +271,14 @@ class FoldersWidget(QtWidgets.QWidget):
the expected selection. Defaults to False.
"""
double_clicked = QtCore.Signal(QtGui.QMouseEvent)
selection_changed = QtCore.Signal()
refreshed = QtCore.Signal()
def __init__(self, controller, parent, handle_expected_selection=False):
super(FoldersWidget, self).__init__(parent)
folders_view = DeselectableTreeView(self)
folders_view = TreeView(self)
folders_view.setHeaderHidden(True)
folders_model = FoldersModel(controller)
@ -295,7 +311,7 @@ class FoldersWidget(QtWidgets.QWidget):
selection_model = folders_view.selectionModel()
selection_model.selectionChanged.connect(self._on_selection_change)
folders_view.double_clicked.connect(self.double_clicked)
folders_model.refreshed.connect(self._on_model_refresh)
self._controller = controller
@ -306,7 +322,27 @@ class FoldersWidget(QtWidgets.QWidget):
self._handle_expected_selection = handle_expected_selection
self._expected_selection = None
def set_name_filer(self, name):
@property
def is_refreshing(self):
"""Model is refreshing.
Returns:
bool: True if model is refreshing.
"""
return self._folders_model.is_refreshing
@property
def has_content(self):
"""Has at least one folder.
Returns:
bool: True if model has at least one folder.
"""
return self._folders_model.has_content
def set_name_filter(self, name):
"""Set filter of folder name.
Args:
@ -323,16 +359,108 @@ class FoldersWidget(QtWidgets.QWidget):
self._folders_model.refresh()
def get_project_name(self):
"""Project name in which folders widget currently is.
Returns:
Union[str, None]: Currently used project name.
"""
return self._folders_model.get_project_name()
def set_project_name(self, project_name):
"""Set project name.
Do not use this method when controller is handling selection of
project using 'selection.project.changed' event.
Args:
project_name (str): Project name.
"""
self._folders_model.set_project_name(project_name)
def get_selected_folder_id(self):
"""Get selected folder id.
Returns:
Union[str, None]: Folder id which is selected.
"""
return self._get_selected_item_id()
def get_selected_folder_label(self):
"""Selected folder label.
Returns:
Union[str, None]: Selected folder label.
"""
item_id = self._get_selected_item_id()
return self.get_folder_label(item_id)
def get_folder_label(self, folder_id):
"""Folder label for a given folder id.
Returns:
Union[str, None]: Folder label.
"""
index = self._folders_model.get_index_by_id(folder_id)
if index.isValid():
return index.data(QtCore.Qt.DisplayRole)
return None
def set_selected_folder(self, folder_id):
"""Change selection.
Args:
folder_id (Union[str, None]): Folder id or None to deselect.
"""
if folder_id is None:
self._folders_view.clearSelection()
return True
if folder_id == self._get_selected_item_id():
return True
index = self._folders_model.get_index_by_id(folder_id)
if not index.isValid():
return False
proxy_index = self._folders_proxy_model.mapFromSource(index)
if not proxy_index.isValid():
return False
selection_model = self._folders_view.selectionModel()
selection_model.setCurrentIndex(
proxy_index, QtCore.QItemSelectionModel.SelectCurrent
)
return True
def set_deselectable(self, enabled):
"""Set deselectable mode.
Items in view can be deselected.
Args:
enabled (bool): Enable deselectable mode.
"""
self._folders_view.set_deselectable(enabled)
def _get_selected_index(self):
return self._folders_model.get_index_by_id(
self.get_selected_folder_id()
)
def _on_project_selection_change(self, event):
project_name = event["project_name"]
self._set_project_name(project_name)
def _set_project_name(self, project_name):
self._folders_model.set_project_name(project_name)
self.set_project_name(project_name)
def _on_folders_refresh_finished(self, event):
if event["sender"] != FOLDERS_MODEL_SENDER_NAME:
self._set_project_name(event["project_name"])
self.set_project_name(event["project_name"])
def _on_controller_refresh(self):
self._update_expected_selection()
@ -341,11 +469,12 @@ class FoldersWidget(QtWidgets.QWidget):
if self._expected_selection:
self._set_expected_selection()
self._folders_proxy_model.sort(0)
self.refreshed.emit()
def _get_selected_item_id(self):
selection_model = self._folders_view.selectionModel()
for index in selection_model.selectedIndexes():
item_id = index.data(ITEM_ID_ROLE)
item_id = index.data(FOLDER_ID_ROLE)
if item_id is not None:
return item_id
return None
@ -353,6 +482,7 @@ class FoldersWidget(QtWidgets.QWidget):
def _on_selection_change(self):
item_id = self._get_selected_item_id()
self._controller.set_selected_folder(item_id)
self.selection_changed.emit()
# Expected selection handling
def _on_expected_selection_change(self, event):
@ -380,12 +510,6 @@ class FoldersWidget(QtWidgets.QWidget):
folder_id = self._expected_selection
self._expected_selection = None
if (
folder_id is not None
and folder_id != self._get_selected_item_id()
):
index = self._folders_model.get_index_by_id(folder_id)
if index.isValid():
proxy_index = self._folders_proxy_model.mapFromSource(index)
self._folders_view.setCurrentIndex(proxy_index)
if folder_id is not None:
self.set_selected_folder(folder_id)
self._controller.expected_folder_selected(folder_id)

View file

@ -395,6 +395,7 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
class ProjectsCombobox(QtWidgets.QWidget):
refreshed = QtCore.Signal()
selection_changed = QtCore.Signal()
def __init__(self, controller, parent, handle_expected_selection=False):
super(ProjectsCombobox, self).__init__(parent)
@ -482,7 +483,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
self._listen_selection_change = listen
def get_current_project_name(self):
def get_selected_project_name(self):
"""Name of selected project.
Returns:
@ -502,7 +503,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
if not self._select_item_visible:
return
if "project_name" not in kwargs:
project_name = self.get_current_project_name()
project_name = self.get_selected_project_name()
else:
project_name = kwargs.get("project_name")
@ -536,6 +537,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
idx, PROJECT_NAME_ROLE)
self._update_select_item_visiblity(project_name=project_name)
self._controller.set_selected_project(project_name)
self.selection_changed.emit()
def _on_model_refresh(self):
self._projects_proxy_model.sort(0)
@ -561,7 +563,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
return
project_name = self._expected_selection
if project_name is not None:
if project_name != self.get_current_project_name():
if project_name != self.get_selected_project_name():
self.set_selection(project_name)
else:
# Fake project change

View file

@ -296,6 +296,9 @@ class TasksWidget(QtWidgets.QWidget):
handle_expected_selection (Optional[bool]): Handle expected selection.
"""
refreshed = QtCore.Signal()
selection_changed = QtCore.Signal()
def __init__(self, controller, parent, handle_expected_selection=False):
super(TasksWidget, self).__init__(parent)
@ -380,6 +383,7 @@ class TasksWidget(QtWidgets.QWidget):
if not self._set_expected_selection():
self._on_selection_change()
self._tasks_proxy_model.sort(0)
self.refreshed.emit()
def _get_selected_item_ids(self):
selection_model = self._tasks_view.selectionModel()
@ -400,6 +404,7 @@ class TasksWidget(QtWidgets.QWidget):
parent_id, task_id, task_name = self._get_selected_item_ids()
self._controller.set_selected_task(task_id, task_name)
self.selection_changed.emit()
# Expected selection handling
def _on_expected_selection_change(self, event):

View file

@ -5,9 +5,10 @@ from openpype.style import (
get_default_entity_icon_color,
get_disabled_entity_icon_color,
)
from openpype.tools.utils import TreeView
from openpype.tools.utils.delegates import PrettyTimeDelegate
from .utils import TreeView, BaseOverlayFrame
from .utils import BaseOverlayFrame
REPRE_ID_ROLE = QtCore.Qt.UserRole + 1
@ -306,7 +307,7 @@ class PublishedFilesWidget(QtWidgets.QWidget):
selection_model = view.selectionModel()
selection_model.selectionChanged.connect(self._on_selection_change)
view.double_clicked_left.connect(self._on_left_double_click)
view.double_clicked.connect(self._on_mouse_double_click)
controller.register_event_callback(
"expected_selection_changed",
@ -350,8 +351,9 @@ class PublishedFilesWidget(QtWidgets.QWidget):
repre_id = self.get_selected_repre_id()
self._controller.set_selected_representation_id(repre_id)
def _on_left_double_click(self):
self.save_as_requested.emit()
def _on_mouse_double_click(self, event):
if event.button() == QtCore.Qt.LeftButton:
self.save_as_requested.emit()
def _on_expected_selection_change(self, event):
if (

View file

@ -5,10 +5,9 @@ from openpype.style import (
get_default_entity_icon_color,
get_disabled_entity_icon_color,
)
from openpype.tools.utils import TreeView
from openpype.tools.utils.delegates import PrettyTimeDelegate
from .utils import TreeView
FILENAME_ROLE = QtCore.Qt.UserRole + 1
FILEPATH_ROLE = QtCore.Qt.UserRole + 2
DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3
@ -271,7 +270,7 @@ class WorkAreaFilesWidget(QtWidgets.QWidget):
selection_model = view.selectionModel()
selection_model.selectionChanged.connect(self._on_selection_change)
view.double_clicked_left.connect(self._on_left_double_click)
view.double_clicked.connect(self._on_mouse_double_click)
view.customContextMenuRequested.connect(self._on_context_menu)
controller.register_event_callback(
@ -333,8 +332,9 @@ class WorkAreaFilesWidget(QtWidgets.QWidget):
filepath = self.get_selected_path()
self._controller.set_selected_workfile_path(filepath)
def _on_left_double_click(self):
self.open_current_requested.emit()
def _on_mouse_double_click(self, event):
if event.button() == QtCore.Qt.LeftButton:
self.save_as_requested.emit()
def _on_context_menu(self, point):
index = self._view.indexAt(point)

View file

@ -264,7 +264,7 @@ class FoldersWidget(QtWidgets.QWidget):
self._expected_selection = None
def set_name_filer(self, name):
def set_name_filter(self, name):
self._folders_proxy_model.setFilterFixedString(name)
def _clear(self):

View file

@ -1,70 +1,4 @@
from qtpy import QtWidgets, QtCore
from openpype.tools.flickcharm import FlickCharm
class TreeView(QtWidgets.QTreeView):
"""Ultimate TreeView with flick charm and double click signals.
Tree view have deselectable mode, which allows to deselect items by
clicking on item area without any items.
Todos:
Add to tools utils.
"""
double_clicked_left = QtCore.Signal()
double_clicked_right = QtCore.Signal()
def __init__(self, *args, **kwargs):
super(TreeView, self).__init__(*args, **kwargs)
self._deselectable = False
self._flick_charm_activated = False
self._flick_charm = FlickCharm(parent=self)
self._before_flick_scroll_mode = None
def is_deselectable(self):
return self._deselectable
def set_deselectable(self, deselectable):
self._deselectable = deselectable
deselectable = property(is_deselectable, set_deselectable)
def mousePressEvent(self, event):
if self._deselectable:
index = self.indexAt(event.pos())
if not index.isValid():
# clear the selection
self.clearSelection()
# clear the current index
self.setCurrentIndex(QtCore.QModelIndex())
super(TreeView, self).mousePressEvent(event)
def mouseDoubleClickEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
self.double_clicked_left.emit()
elif event.button() == QtCore.Qt.RightButton:
self.double_clicked_right.emit()
return super(TreeView, self).mouseDoubleClickEvent(event)
def activate_flick_charm(self):
if self._flick_charm_activated:
return
self._flick_charm_activated = True
self._before_flick_scroll_mode = self.verticalScrollMode()
self._flick_charm.activateOn(self)
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
def deactivate_flick_charm(self):
if not self._flick_charm_activated:
return
self._flick_charm_activated = False
self._flick_charm.deactivateFrom(self)
if self._before_flick_scroll_mode is not None:
self.setVerticalScrollMode(self._before_flick_scroll_mode)
class BaseOverlayFrame(QtWidgets.QFrame):

View file

@ -338,7 +338,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
self._side_panel.set_published_mode(published_mode)
def _on_folder_filter_change(self, text):
self._folder_widget.set_name_filer(text)
self._folder_widget.set_name_filter(text)
def _on_go_to_current_clicked(self):
self._controller.go_to_current_context()

View file

@ -20,7 +20,10 @@ from .widgets import (
RefreshButton,
GoToCurrentButton,
)
from .views import DeselectableTreeView
from .views import (
DeselectableTreeView,
TreeView,
)
from .error_dialog import ErrorMessageBox
from .lib import (
WrappedCallbackItem,
@ -71,6 +74,7 @@ __all__ = (
"GoToCurrentButton",
"DeselectableTreeView",
"TreeView",
"ErrorMessageBox",

View file

@ -24,9 +24,12 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
lock = False
def __init__(self, dbcon, *args, **kwargs):
self.dbcon = dbcon
self._dbcon = dbcon
super(VersionDelegate, self).__init__(*args, **kwargs)
def get_project_name(self):
return self._dbcon.active_project()
def displayText(self, value, locale):
if isinstance(value, HeroVersionType):
return lib.format_version(value, True)
@ -120,7 +123,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
"Version is not integer"
)
project_name = self.dbcon.active_project()
project_name = self.get_project_name()
# Add all available versions to the editor
parent_id = item["version_document"]["parent"]
version_docs = [

View file

@ -171,14 +171,23 @@ class HostToolsHelper:
def get_scene_inventory_tool(self, parent):
"""Create, cache and return scene inventory tool window."""
if self._scene_inventory_tool is None:
from openpype.tools.sceneinventory import SceneInventoryWindow
host = registered_host()
ILoadHost.validate_load_methods(host)
scene_inventory_window = SceneInventoryWindow(
parent=parent or self._parent
)
if AYON_SERVER_ENABLED:
from openpype.tools.ayon_sceneinventory.window import (
SceneInventoryWindow)
scene_inventory_window = SceneInventoryWindow(
parent=parent or self._parent
)
else:
from openpype.tools.sceneinventory import SceneInventoryWindow
scene_inventory_window = SceneInventoryWindow(
parent=parent or self._parent
)
self._scene_inventory_tool = scene_inventory_window
return self._scene_inventory_tool

View file

@ -1,4 +1,6 @@
from openpype.resources import get_image_path
from openpype.tools.flickcharm import FlickCharm
from qtpy import QtWidgets, QtCore, QtGui, QtSvg
@ -57,3 +59,63 @@ class TreeViewSpinner(QtWidgets.QTreeView):
self.paint_empty(event)
else:
super(TreeViewSpinner, self).paintEvent(event)
class TreeView(QtWidgets.QTreeView):
"""Ultimate TreeView with flick charm and double click signals.
Tree view have deselectable mode, which allows to deselect items by
clicking on item area without any items.
Todos:
Add refresh animation.
"""
double_clicked = QtCore.Signal(QtGui.QMouseEvent)
def __init__(self, *args, **kwargs):
super(TreeView, self).__init__(*args, **kwargs)
self._deselectable = False
self._flick_charm_activated = False
self._flick_charm = FlickCharm(parent=self)
self._before_flick_scroll_mode = None
def is_deselectable(self):
return self._deselectable
def set_deselectable(self, deselectable):
self._deselectable = deselectable
deselectable = property(is_deselectable, set_deselectable)
def mousePressEvent(self, event):
if self._deselectable:
index = self.indexAt(event.pos())
if not index.isValid():
# clear the selection
self.clearSelection()
# clear the current index
self.setCurrentIndex(QtCore.QModelIndex())
super(TreeView, self).mousePressEvent(event)
def mouseDoubleClickEvent(self, event):
self.double_clicked.emit(event)
return super(TreeView, self).mouseDoubleClickEvent(event)
def activate_flick_charm(self):
if self._flick_charm_activated:
return
self._flick_charm_activated = True
self._before_flick_scroll_mode = self.verticalScrollMode()
self._flick_charm.activateOn(self)
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
def deactivate_flick_charm(self):
if not self._flick_charm_activated:
return
self._flick_charm_activated = False
self._flick_charm.deactivateFrom(self)
if self._before_flick_scroll_mode is not None:
self.setVerticalScrollMode(self._before_flick_scroll_mode)

View file

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

View file

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

View file

@ -7,6 +7,26 @@
"host_name": "maya",
"environment": "{\n \"MAYA_DISABLE_CLIC_IPM\": \"Yes\",\n \"MAYA_DISABLE_CIP\": \"Yes\",\n \"MAYA_DISABLE_CER\": \"Yes\",\n \"PYMEL_SKIP_MEL_INIT\": \"Yes\",\n \"LC_ALL\": \"C\"\n}\n",
"variants": [
{
"name": "2024",
"label": "2024",
"executables": {
"windows": [
"C:\\Program Files\\Autodesk\\Maya2024\\bin\\maya.exe"
],
"darwin": [],
"linux": [
"/usr/autodesk/maya2024/bin/maya"
]
},
"arguments": {
"windows": [],
"darwin": [],
"linux": []
},
"environment": "{\n \"MAYA_VERSION\": \"2024\"\n}",
"use_python_2": false
},
{
"name": "2023",
"label": "2023",
@ -45,66 +65,6 @@
"linux": []
},
"environment": "{\n \"MAYA_VERSION\": \"2022\"\n}",
"use_python_2": false
},
{
"name": "2020",
"label": "2020",
"executables": {
"windows": [
"C:\\Program Files\\Autodesk\\Maya2020\\bin\\maya.exe"
],
"darwin": [],
"linux": [
"/usr/autodesk/maya2020/bin/maya"
]
},
"arguments": {
"windows": [],
"darwin": [],
"linux": []
},
"environment": "{\n \"MAYA_VERSION\": \"2020\"\n}",
"use_python_2": true
},
{
"name": "2019",
"label": "2019",
"executables": {
"windows": [
"C:\\Program Files\\Autodesk\\Maya2019\\bin\\maya.exe"
],
"darwin": [],
"linux": [
"/usr/autodesk/maya2019/bin/maya"
]
},
"arguments": {
"windows": [],
"darwin": [],
"linux": []
},
"environment": "{\n \"MAYA_VERSION\": \"2019\"\n}",
"use_python_2": true
},
{
"name": "2018",
"label": "2018",
"executables": {
"windows": [
"C:\\Program Files\\Autodesk\\Maya2018\\bin\\maya.exe"
],
"darwin": [],
"linux": [
"/usr/autodesk/maya2018/bin/maya"
]
},
"arguments": {
"windows": [],
"darwin": [],
"linux": []
},
"environment": "{\n \"MAYA_VERSION\": \"2018\"\n}",
"use_python_2": true
}
]

View file

@ -115,9 +115,7 @@ class ToolGroupModel(BaseSettingsModel):
name: str = Field("", title="Name")
label: str = Field("", title="Label")
environment: str = Field("{}", title="Environments", widget="textarea")
variants: list[ToolVariantModel] = Field(
default_factory=ToolVariantModel
)
variants: list[ToolVariantModel] = Field(default_factory=list)
@validator("environment")
def validate_json(cls, value):

View file

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

View file

@ -1,6 +1,7 @@
from pydantic import Field
from ayon_server.settings import BaseSettingsModel
from ayon_server.settings import task_types_enum
class CreateLookModel(BaseSettingsModel):
@ -120,6 +121,16 @@ class CreateVrayProxyModel(BaseSettingsModel):
default_factory=list, title="Default Products")
class CreateMultishotLayout(BasicCreatorModel):
shotParent: str = Field(title="Shot Parent Folder")
groupLoadedAssets: bool = Field(title="Group Loaded Assets")
task_type: list[str] = Field(
title="Task types",
enum_resolver=task_types_enum
)
task_name: str = Field(title="Task name (regex)")
class CreatorsModel(BaseSettingsModel):
CreateLook: CreateLookModel = Field(
default_factory=CreateLookModel,

View file

@ -236,7 +236,7 @@ class PublishPuginsModel(BaseSettingsModel):
default_factory=CollectInstanceDataModel,
section="Collectors"
)
ValidateCorrectAssetName: OptionalPluginModel = Field(
ValidateCorrectAssetContext: OptionalPluginModel = Field(
title="Validate Correct Folder Name",
default_factory=OptionalPluginModel,
section="Validators"
@ -308,7 +308,7 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = {
"write"
]
},
"ValidateCorrectAssetName": {
"ValidateCorrectAssetContext": {
"enabled": True,
"optional": True,
"active": True

View file

@ -0,0 +1,28 @@
import logging
import sys
from maya import cmds
import pyblish.util
def setup_pyblish_logging():
log = logging.getLogger("pyblish")
handler = logging.StreamHandler(sys.stdout)
formatter = logging.Formatter(
"pyblish (%(levelname)s) (line: %(lineno)d) %(name)s:"
"\n%(message)s"
)
handler.setFormatter(formatter)
log.addHandler(handler)
def _run_publish_test_deferred():
try:
setup_pyblish_logging()
pyblish.util.publish()
finally:
cmds.quit(force=True)
cmds.evalDeferred("_run_publish_test_deferred()", lowestPriority=True)

View file

@ -33,16 +33,16 @@ class MayaHostFixtures(HostFixtures):
yield dest_path
@pytest.fixture(scope="module")
def startup_scripts(self, monkeypatch_session, download_test_data):
def startup_scripts(self, monkeypatch_session):
"""Points Maya to userSetup file from input data"""
startup_path = os.path.join(download_test_data,
"input",
"startup")
startup_path = os.path.join(
os.path.dirname(__file__), "input", "startup"
)
original_pythonpath = os.environ.get("PYTHONPATH")
monkeypatch_session.setenv("PYTHONPATH",
"{}{}{}".format(startup_path,
os.pathsep,
original_pythonpath))
monkeypatch_session.setenv(
"PYTHONPATH",
"{}{}{}".format(startup_path, os.pathsep, original_pythonpath)
)
@pytest.fixture(scope="module")
def skip_compare_folders(self):

View file

@ -105,7 +105,7 @@ class ModuleUnitTest(BaseTest):
yield path
@pytest.fixture(scope="module")
def env_var(self, monkeypatch_session, download_test_data):
def env_var(self, monkeypatch_session, download_test_data, mongo_url):
"""Sets temporary env vars from json file."""
env_url = os.path.join(download_test_data, "input",
"env_vars", "env_var.json")
@ -129,6 +129,9 @@ class ModuleUnitTest(BaseTest):
monkeypatch_session.setenv(key, str(value))
#reset connection to openpype DB with new env var
if mongo_url:
monkeypatch_session.setenv("OPENPYPE_MONGO", mongo_url)
import openpype.settings.lib as sett_lib
sett_lib._SETTINGS_HANDLER = None
sett_lib._LOCAL_SETTINGS_HANDLER = None
@ -150,8 +153,7 @@ class ModuleUnitTest(BaseTest):
request, mongo_url):
"""Restore prepared MongoDB dumps into selected DB."""
backup_dir = os.path.join(download_test_data, "input", "dumps")
uri = mongo_url or os.environ.get("OPENPYPE_MONGO")
uri = os.environ.get("OPENPYPE_MONGO")
db_handler = DBHandler(uri)
db_handler.setup_from_dump(self.TEST_DB_NAME, backup_dir,
overwrite=True,