Merge branch 'develop' into bugfix/OP-2865_Loading-maya-reviews-into-resolve

This commit is contained in:
Jakub Ježek 2023-05-24 22:06:43 +02:00 committed by GitHub
commit 5fcb19bc67
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
77 changed files with 3245 additions and 1065 deletions

View file

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

View file

@ -1,6 +1,304 @@
# Changelog
## [3.15.8](https://github.com/ynput/OpenPype/tree/3.15.8)
[Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.7...3.15.8)
### **🆕 New features**
<details>
<summary>Publisher: Show instances in report page <a href="https://github.com/ynput/OpenPype/pull/4915">#4915</a></summary>
Show publish instances in report page. Also added basic log view with logs grouped by instance. Validation error detail now have 2 colums, one with erro details second with logs. Crashed state shows fast access to report action buttons. Success will show only logs. Publish frame is shrunked automatically on publish stop.
___
</details>
<details>
<summary>Fusion - Loader plugins updates <a href="https://github.com/ynput/OpenPype/pull/4920">#4920</a></summary>
Update to some Fusion loader plugins:The sequence loader can now load footage from the image and online family.The FBX loader can now import all formats Fusions FBX node can read.You can now import the content of another workfile into your current comp with the workfile loader.
___
</details>
<details>
<summary>Fusion: deadline farm rendering <a href="https://github.com/ynput/OpenPype/pull/4955">#4955</a></summary>
Enabling Fusion for deadline farm rendering.
___
</details>
<details>
<summary>AfterEffects: set frame range and resolution <a href="https://github.com/ynput/OpenPype/pull/4983">#4983</a></summary>
Frame information (frame start, duration, fps) and resolution (width and height) is applied to selected composition from Asset Management System (Ftrack or DB) automatically when published instance is created.It is also possible explicitly propagate both values from DB to selected composition by newly added menu buttons.
___
</details>
<details>
<summary>Publish: Enhance automated publish plugin settings <a href="https://github.com/ynput/OpenPype/pull/4986">#4986</a></summary>
Added plugins option to define settings category where to look for settings of a plugin and added public helper functions to apply settings `get_plugin_settings` and `apply_plugin_settings_automatically`.
___
</details>
### **🚀 Enhancements**
<details>
<summary>Load Rig References - Change Rig to Animation in Animation instance <a href="https://github.com/ynput/OpenPype/pull/4877">#4877</a></summary>
We are using the template builder to build an animation scene. All the rig placeholders are imported correctly, but the automatically created animation instances retain the rig family in their names and subsets. In our example, we need animationMain instead of rigMain, because this name will be used in the following steps like lighting.Here is the result we need. I checked, and it's not a template builder problem, because even if I load a rig as a reference, the result is the same. For me, since we are in the animation instance, it makes more sense to have animation instead of rig in the name. The naming is just fine if we use create from the Openpype menu.
___
</details>
<details>
<summary>Enhancement: Resolve prelaunch code refactoring and update defaults <a href="https://github.com/ynput/OpenPype/pull/4916">#4916</a></summary>
The main reason of this PR is wrong default settings in `openpype/settings/defaults/system_settings/applications.json` for Resolve host. The `bin` folder should not be a part of the macos and Linux `RESOLVE_PYTHON3_PATH` variable.The rest of this PR is some code cleanups for Resolve prelaunch hook to simplify further development.Also added a .gitignore for vscode workspace files.
___
</details>
<details>
<summary>Unreal: 🚚 move Unreal plugin to separate repository <a href="https://github.com/ynput/OpenPype/pull/4980">#4980</a></summary>
To support Epic Marketplace have to move AYON Unreal integration plugins to separate repository. This is replacing current files with git submodule, so the change should be functionally without impact.New repository lives here: https://github.com/ynput/ayon-unreal-plugin
___
</details>
<details>
<summary>General: Lib code cleanup <a href="https://github.com/ynput/OpenPype/pull/5003">#5003</a></summary>
Small cleanup in lib files in openpype.
___
</details>
<details>
<summary>Allow to open with djv by extension instead of representation name <a href="https://github.com/ynput/OpenPype/pull/5004">#5004</a></summary>
Filter open in djv action by extension instead of representation.
___
</details>
<details>
<summary>DJV open action `extensions` as `set` <a href="https://github.com/ynput/OpenPype/pull/5005">#5005</a></summary>
Change `extensions` attribute to `set`.
___
</details>
<details>
<summary>Nuke: extract thumbnail with multiple reposition nodes <a href="https://github.com/ynput/OpenPype/pull/5011">#5011</a></summary>
Added support for multiple reposition nodes.
___
</details>
<details>
<summary>Enhancement: Improve logging levels and messages for artist facing publish reports <a href="https://github.com/ynput/OpenPype/pull/5018">#5018</a></summary>
Tweak the logging levels and messages to try and only show those logs that an artist should see and could understand. Move anything that's slightly more involved into a "debug" message instead.
___
</details>
### **🐛 Bug fixes**
<details>
<summary>Bugfix/frame variable fix <a href="https://github.com/ynput/OpenPype/pull/4978">#4978</a></summary>
Renamed variables to match OpenPype terminology to reduce confusion and add consistency.
___
</details>
<details>
<summary>Global: plugins cleanup plugin will leave beauty rendered files <a href="https://github.com/ynput/OpenPype/pull/4790">#4790</a></summary>
Attempt to mark more files to be cleaned up explicitly in intermediate `renders` folder in work area for farm jobs.
___
</details>
<details>
<summary>Fix: Download last workfile doesn't work if not already downloaded <a href="https://github.com/ynput/OpenPype/pull/4942">#4942</a></summary>
Some optimization condition is messing with the feature: if the published workfile is not already downloaded, it won't download it...
___
</details>
<details>
<summary>Unreal: Fix transform when loading layout to match existing assets <a href="https://github.com/ynput/OpenPype/pull/4972">#4972</a></summary>
Fixed transform when loading layout to match existing assets.
___
</details>
<details>
<summary>fix the bug of fbx loaders in Max <a href="https://github.com/ynput/OpenPype/pull/4977">#4977</a></summary>
bug fix of fbx loaders for not being able to parent to the CON instances while importing cameras(and models) which is published from other DCCs such as Maya.
___
</details>
<details>
<summary>AfterEffects: allow returning stub with not saved workfile <a href="https://github.com/ynput/OpenPype/pull/4984">#4984</a></summary>
Allows to use Workfile app to Save first empty workfile.
___
</details>
<details>
<summary>Blender: Fix Alembic loading <a href="https://github.com/ynput/OpenPype/pull/4985">#4985</a></summary>
Fixed problem occurring when trying to load an Alembic model in Blender.
___
</details>
<details>
<summary>Unreal: Addon Py2 compatibility <a href="https://github.com/ynput/OpenPype/pull/4994">#4994</a></summary>
Fixed Python 2 compatibility of unreal addon.
___
</details>
<details>
<summary>Nuke: fixed missing files key in representation <a href="https://github.com/ynput/OpenPype/pull/4999">#4999</a></summary>
Issue with missing keys once rendering target set to existing frames is fixed. Instance has to be evaluated in validation for missing files.
___
</details>
<details>
<summary>Unreal: Fix the frame range when loading camera <a href="https://github.com/ynput/OpenPype/pull/5002">#5002</a></summary>
The keyframes of the camera, when loaded, were not using the correct frame range.
___
</details>
<details>
<summary>Fusion: fixing frame range targeting <a href="https://github.com/ynput/OpenPype/pull/5013">#5013</a></summary>
Frame range targeting at Rendering instances is now following configured options.
___
</details>
<details>
<summary>Deadline: fix selection from multiple webservices <a href="https://github.com/ynput/OpenPype/pull/5015">#5015</a></summary>
Multiple different DL webservice could be configured. First they must by configured in System Settings., then they could be configured per project in `project_settings/deadline/deadline_servers`.Only single webservice could be a target of publish though.
___
</details>
### **Merged pull requests**
<details>
<summary>3dsmax: Refactored publish plugins to use proper implementation of pymxs <a href="https://github.com/ynput/OpenPype/pull/4988">#4988</a></summary>
___
</details>
## [3.15.7](https://github.com/ynput/OpenPype/tree/3.15.7)

View file

@ -256,8 +256,11 @@ def switch_item(container,
@contextlib.contextmanager
def maintained_selection():
comp = get_current_comp()
def maintained_selection(comp=None):
"""Reset comp selection from before the context after the context"""
if comp is None:
comp = get_current_comp()
previous_selection = comp.GetToolList(True).values()
try:
yield
@ -269,6 +272,33 @@ def maintained_selection():
flow.Select(tool, True)
@contextlib.contextmanager
def maintained_comp_range(comp=None,
global_start=True,
global_end=True,
render_start=True,
render_end=True):
"""Reset comp frame ranges from before the context after the context"""
if comp is None:
comp = get_current_comp()
comp_attrs = comp.GetAttrs()
preserve_attrs = {}
if global_start:
preserve_attrs["COMPN_GlobalStart"] = comp_attrs["COMPN_GlobalStart"]
if global_end:
preserve_attrs["COMPN_GlobalEnd"] = comp_attrs["COMPN_GlobalEnd"]
if render_start:
preserve_attrs["COMPN_RenderStart"] = comp_attrs["COMPN_RenderStart"]
if render_end:
preserve_attrs["COMPN_RenderEnd"] = comp_attrs["COMPN_RenderEnd"]
try:
yield
finally:
comp.SetAttrs(preserve_attrs)
def get_frame_path(path):
"""Get filename for the Fusion Saver with padded number as '#'

View file

@ -233,7 +233,7 @@ class CreateSaver(NewCreator):
def _get_frame_range_enum(self):
frame_range_options = {
"asset_db": "Current asset context",
"render_range": "From viewer render in/out",
"render_range": "From render in/out",
"comp_range": "From composition timeline"
}

View file

@ -113,4 +113,4 @@ class CollectUpstreamInputs(pyblish.api.InstancePlugin):
inputs = [c["representation"] for c in containers]
instance.data["inputRepresentations"] = inputs
self.log.info("Collected inputs: %s" % inputs)
self.log.debug("Collected inputs: %s" % inputs)

View file

@ -17,6 +17,8 @@ class FusionRenderInstance(RenderInstance):
tool = attr.ib(default=None)
workfileComp = attr.ib(default=None)
publish_attributes = attr.ib(default={})
frameStartHandle = attr.ib(default=None)
frameEndHandle = attr.ib(default=None)
class CollectFusionRender(
@ -83,8 +85,8 @@ class CollectFusionRender(
frameEnd=inst.data["frameEnd"],
handleStart=inst.data["handleStart"],
handleEnd=inst.data["handleEnd"],
ignoreFrameHandleCheck=(
inst.data["frame_range_source"] == "render_range"),
frameStartHandle=inst.data["frameStartHandle"],
frameEndHandle=inst.data["frameEndHandle"],
frameStep=1,
fps=comp_frame_format_prefs.get("Rate"),
app_version=comp.GetApp().Version,

View file

@ -1,11 +1,12 @@
import os
import logging
import contextlib
import collections
import pyblish.api
from openpype.pipeline import publish
from openpype.hosts.fusion.api import comp_lock_and_undo_chunk
from openpype.hosts.fusion.api.lib import get_frame_path
from openpype.hosts.fusion.api.lib import get_frame_path, maintained_comp_range
log = logging.getLogger(__name__)
@ -52,11 +53,14 @@ class FusionRenderLocal(
hosts = ["fusion"]
families = ["render.local"]
is_rendered_key = "_fusionrenderlocal_has_rendered"
def process(self, instance):
context = instance.context
# Start render
self.render_once(context)
result = self.render(instance)
if result is False:
raise RuntimeError(f"Comp render failed for {instance}")
self._add_representation(instance)
@ -69,39 +73,48 @@ class FusionRenderLocal(
)
)
def render_once(self, context):
"""Render context comp only once, even with more render instances"""
def render(self, instance):
"""Render instance.
# This plug-in assumes all render nodes get rendered at the same time
# to speed up the rendering. The check below makes sure that we only
# execute the rendering once and not for each instance.
key = f"__hasRun{self.__class__.__name__}"
We try to render the minimal amount of times by combining the instances
that have a matching frame range in one Fusion render. Then for the
batch of instances we store whether the render succeeded or failed.
savers_to_render = [
# Get the saver tool from the instance
instance.data["tool"] for instance in context if
# Only active instances
instance.data.get("publish", True) and
# Only render.local instances
"render.local" in instance.data.get("families", [])
]
"""
if key not in context.data:
# We initialize as false to indicate it wasn't successful yet
# so we can keep track of whether Fusion succeeded
context.data[key] = False
if self.is_rendered_key in instance.data:
# This instance was already processed in batch with another
# instance, so we just return the render result directly
self.log.debug(f"Instance {instance} was already rendered")
return instance.data[self.is_rendered_key]
current_comp = context.data["currentComp"]
frame_start = context.data["frameStartHandle"]
frame_end = context.data["frameEndHandle"]
instances_by_frame_range = self.get_render_instances_by_frame_range(
instance.context
)
self.log.info("Starting Fusion render")
self.log.info(f"Start frame: {frame_start}")
self.log.info(f"End frame: {frame_end}")
saver_names = ", ".join(saver.Name for saver in savers_to_render)
self.log.info(f"Rendering tools: {saver_names}")
# Render matching batch of instances that share the same frame range
frame_range = self.get_instance_render_frame_range(instance)
render_instances = instances_by_frame_range[frame_range]
with comp_lock_and_undo_chunk(current_comp):
# We initialize render state false to indicate it wasn't successful
# yet to keep track of whether Fusion succeeded. This is for cases
# where an error below this might cause the comp render result not
# to be stored for the instances of this batch
for render_instance in render_instances:
render_instance.data[self.is_rendered_key] = False
savers_to_render = [inst.data["tool"] for inst in render_instances]
current_comp = instance.context.data["currentComp"]
frame_start, frame_end = frame_range
self.log.info(
f"Starting Fusion render frame range {frame_start}-{frame_end}"
)
saver_names = ", ".join(saver.Name for saver in savers_to_render)
self.log.info(f"Rendering tools: {saver_names}")
with comp_lock_and_undo_chunk(current_comp):
with maintained_comp_range(current_comp):
with enabled_savers(current_comp, savers_to_render):
result = current_comp.Render(
{
@ -111,10 +124,11 @@ class FusionRenderLocal(
}
)
context.data[key] = bool(result)
# Store the render state for all the rendered instances
for render_instance in render_instances:
render_instance.data[self.is_rendered_key] = bool(result)
if context.data[key] is False:
raise RuntimeError("Comp render failed")
return result
def _add_representation(self, instance):
"""Add representation to instance"""
@ -151,3 +165,35 @@ class FusionRenderLocal(
instance.data["representations"].append(repre)
return instance
def get_render_instances_by_frame_range(self, context):
"""Return enabled render.local instances grouped by their frame range.
Arguments:
context (pyblish.Context): The pyblish context
Returns:
dict: (start, end): instances mapping
"""
instances_to_render = [
instance for instance in context if
# Only active instances
instance.data.get("publish", True) and
# Only render.local instances
"render.local" in instance.data.get("families", [])
]
# Instances by frame ranges
instances_by_frame_range = collections.defaultdict(list)
for instance in instances_to_render:
start, end = self.get_instance_render_frame_range(instance)
instances_by_frame_range[(start, end)].append(instance)
return dict(instances_by_frame_range)
def get_instance_render_frame_range(self, instance):
start = instance.data["frameStartHandle"]
end = instance.data["frameEndHandle"]
return start, end

View file

@ -17,5 +17,5 @@ class FusionSaveComp(pyblish.api.ContextPlugin):
current = comp.GetAttrs().get("COMPS_FileName", "")
assert context.data['currentFile'] == current
self.log.info("Saving current file..")
self.log.info("Saving current file: {}".format(current))
comp.Save()

View file

@ -0,0 +1,41 @@
import pyblish.api
from openpype.pipeline import PublishValidationError
class ValidateInstanceFrameRange(pyblish.api.InstancePlugin):
"""Validate instance frame range is within comp's global render range."""
order = pyblish.api.ValidatorOrder
label = "Validate Filename Has Extension"
families = ["render"]
hosts = ["fusion"]
def process(self, instance):
context = instance.context
global_start = context.data["compFrameStart"]
global_end = context.data["compFrameEnd"]
render_start = instance.data["frameStartHandle"]
render_end = instance.data["frameEndHandle"]
if render_start < global_start or render_end > global_end:
message = (
f"Instance {instance} render frame range "
f"({render_start}-{render_end}) is outside of the comp's "
f"global render range ({global_start}-{global_end}) and thus "
f"can't be rendered. "
)
description = (
f"{message}\n\n"
f"Either update the comp's global range or the instance's "
f"frame range to ensure the comp's frame range includes the "
f"to render frame range for the instance."
)
raise PublishValidationError(
title="Frame range outside of comp range",
message=message,
description=description
)

View file

@ -8,7 +8,6 @@ import pyblish.api
from openpype.hosts.houdini.api import lib
class CollectFrames(pyblish.api.InstancePlugin):
"""Collect all frames which would be saved from the ROP nodes"""
@ -34,8 +33,10 @@ class CollectFrames(pyblish.api.InstancePlugin):
self.log.warning("Using current frame: {}".format(hou.frame()))
output = output_parm.eval()
_, ext = lib.splitext(output,
allowed_multidot_extensions=[".ass.gz"])
_, ext = lib.splitext(
output,
allowed_multidot_extensions=[".ass.gz"]
)
file_name = os.path.basename(output)
result = file_name

View file

@ -117,4 +117,4 @@ class CollectUpstreamInputs(pyblish.api.InstancePlugin):
inputs = [c["representation"] for c in containers]
instance.data["inputRepresentations"] = inputs
self.log.info("Collected inputs: %s" % inputs)
self.log.debug("Collected inputs: %s" % inputs)

View file

@ -55,7 +55,9 @@ class CollectInstances(pyblish.api.ContextPlugin):
has_family = node.evalParm("family")
assert has_family, "'%s' is missing 'family'" % node.name()
self.log.info("processing {}".format(node))
self.log.info(
"Processing legacy instance node {}".format(node.path())
)
data = lib.read(node)
# Check bypass state and reverse

View file

@ -32,5 +32,4 @@ class CollectWorkfile(pyblish.api.InstancePlugin):
"stagingDir": folder,
}]
self.log.info('Collected instance: {}'.format(file))
self.log.info('staging Dir: {}'.format(folder))
self.log.debug('Collected workfile instance: {}'.format(file))

View file

@ -20,7 +20,7 @@ class SaveCurrentScene(pyblish.api.ContextPlugin):
)
if host.has_unsaved_changes():
self.log.info("Saving current file {}...".format(current_file))
self.log.info("Saving current file: {}".format(current_file))
host.save_workfile(current_file)
else:
self.log.debug("No unsaved changes, skipping file save..")

View file

@ -28,18 +28,37 @@ class ValidateWorkfilePaths(
if not self.is_active(instance.data):
return
invalid = self.get_invalid()
self.log.info(
"node types to check: {}".format(", ".join(self.node_types)))
self.log.info(
"prohibited vars: {}".format(", ".join(self.prohibited_vars))
self.log.debug(
"Checking node types: {}".format(", ".join(self.node_types)))
self.log.debug(
"Searching prohibited vars: {}".format(
", ".join(self.prohibited_vars)
)
)
if invalid:
for param in invalid:
self.log.error(
"{}: {}".format(param.path(), param.unexpandedString()))
raise PublishValidationError(
"Invalid paths found", title=self.label)
if invalid:
all_container_vars = set()
for param in invalid:
value = param.unexpandedString()
contained_vars = [
var for var in self.prohibited_vars
if var in value
]
all_container_vars.update(contained_vars)
self.log.error(
"Parm {} contains prohibited vars {}: {}".format(
param.path(),
", ".join(contained_vars),
value)
)
message = (
"Prohibited vars {} found in parameter values".format(
", ".join(all_container_vars)
)
)
raise PublishValidationError(message, title=self.label)
@classmethod
def get_invalid(cls):
@ -63,7 +82,7 @@ class ValidateWorkfilePaths(
def repair(cls, instance):
invalid = cls.get_invalid()
for param in invalid:
cls.log.info("processing: {}".format(param.path()))
cls.log.info("Processing: {}".format(param.path()))
cls.log.info("Replacing {} for {}".format(
param.unexpandedString(),
hou.text.expandString(param.unexpandedString())))

View file

@ -166,7 +166,7 @@ class CollectUpstreamInputs(pyblish.api.InstancePlugin):
inputs = [c["representation"] for c in containers]
instance.data["inputRepresentations"] = inputs
self.log.info("Collected inputs: %s" % inputs)
self.log.debug("Collected inputs: %s" % inputs)
def _collect_renderlayer_inputs(self, scene_containers, instance):
"""Collects inputs from nodes in renderlayer, incl. shaders + camera"""

View file

@ -31,5 +31,5 @@ class SaveCurrentScene(pyblish.api.ContextPlugin):
# remove lockfile before saving
if is_workfile_lock_enabled("maya", project_name, project_settings):
remove_workfile_lock(current)
self.log.info("Saving current file..")
self.log.info("Saving current file: {}".format(current))
cmds.file(save=True, force=True)

View file

@ -5,6 +5,8 @@ import pyblish.api
from openpype.pipeline import publish
from openpype.hosts.nuke import api as napi
from openpype.hosts.nuke.api.lib import set_node_knobs_from_settings
if sys.version_info[0] >= 3:
unicode = str
@ -28,7 +30,7 @@ class ExtractThumbnail(publish.Extractor):
bake_viewer_process = True
bake_viewer_input_process = True
nodes = {}
reposition_nodes = None
def process(self, instance):
if instance.data.get("farm"):
@ -123,18 +125,32 @@ class ExtractThumbnail(publish.Extractor):
temporary_nodes.append(rnode)
previous_node = rnode
reformat_node = nuke.createNode("Reformat")
ref_node = self.nodes.get("Reformat", None)
if ref_node:
for k, v in ref_node:
self.log.debug("k, v: {0}:{1}".format(k, v))
if isinstance(v, unicode):
v = str(v)
reformat_node[k].setValue(v)
if self.reposition_nodes is None:
# [deprecated] create reformat node old way
reformat_node = nuke.createNode("Reformat")
ref_node = self.nodes.get("Reformat", None)
if ref_node:
for k, v in ref_node:
self.log.debug("k, v: {0}:{1}".format(k, v))
if isinstance(v, unicode):
v = str(v)
reformat_node[k].setValue(v)
reformat_node.setInput(0, previous_node)
previous_node = reformat_node
temporary_nodes.append(reformat_node)
reformat_node.setInput(0, previous_node)
previous_node = reformat_node
temporary_nodes.append(reformat_node)
else:
# create reformat node new way
for repo_node in self.reposition_nodes:
node_class = repo_node["node_class"]
knobs = repo_node["knobs"]
node = nuke.createNode(node_class)
set_node_knobs_from_settings(node, knobs)
# connect in order
node.setInput(0, previous_node)
previous_node = node
temporary_nodes.append(node)
# only create colorspace baking if toggled on
if bake_viewer_process:

View file

@ -16,11 +16,12 @@ class SaveCurrentWorkfile(pyblish.api.ContextPlugin):
def process(self, context):
host = registered_host()
if context.data["currentFile"] != host.get_current_workfile():
current = host.get_current_workfile()
if context.data["currentFile"] != current:
raise KnownPublishError("Workfile has changed during publishing!")
if host.has_unsaved_changes():
self.log.info("Saving current file..")
self.log.info("Saving current file: {}".format(current))
host.save_workfile()
else:
self.log.debug("Skipping workfile save because there are no "

View file

@ -156,7 +156,7 @@ class AnimationFBXLoader(plugin.Loader):
package_paths=[f"{root}/{hierarchy[0]}"],
recursive_paths=False)
levels = ar.get_assets(_filter)
master_level = levels[0].get_full_name()
master_level = levels[0].get_asset().get_path_name()
hierarchy_dir = root
for h in hierarchy:
@ -168,7 +168,7 @@ class AnimationFBXLoader(plugin.Loader):
package_paths=[f"{hierarchy_dir}/"],
recursive_paths=True)
levels = ar.get_assets(_filter)
level = levels[0].get_full_name()
level = levels[0].get_asset().get_path_name()
unreal.EditorLevelLibrary.save_all_dirty_levels()
unreal.EditorLevelLibrary.load_level(level)

View file

@ -365,7 +365,7 @@ class CameraLoader(plugin.Loader):
maps = ar.get_assets(filter)
# There should be only one map in the list
EditorLevelLibrary.load_level(maps[0].get_full_name())
EditorLevelLibrary.load_level(maps[0].get_asset().get_path_name())
level_sequence = sequences[0].get_asset()
@ -513,7 +513,7 @@ class CameraLoader(plugin.Loader):
map = maps[0]
EditorLevelLibrary.save_all_dirty_levels()
EditorLevelLibrary.load_level(map.get_full_name())
EditorLevelLibrary.load_level(map.get_asset().get_path_name())
# Remove the camera from the level.
actors = EditorLevelLibrary.get_all_level_actors()
@ -523,7 +523,7 @@ class CameraLoader(plugin.Loader):
EditorLevelLibrary.destroy_actor(a)
EditorLevelLibrary.save_all_dirty_levels()
EditorLevelLibrary.load_level(world.get_full_name())
EditorLevelLibrary.load_level(world.get_asset().get_path_name())
# There should be only one sequence in the path.
sequence_name = sequences[0].asset_name

View file

@ -740,7 +740,7 @@ class LayoutLoader(plugin.Loader):
loaded_assets = self._process(self.fname, asset_dir, shot)
for s in sequences:
EditorAssetLibrary.save_asset(s.get_full_name())
EditorAssetLibrary.save_asset(s.get_path_name())
EditorLevelLibrary.save_current_level()
@ -819,7 +819,7 @@ class LayoutLoader(plugin.Loader):
recursive_paths=False)
levels = ar.get_assets(filter)
layout_level = levels[0].get_full_name()
layout_level = levels[0].get_asset().get_path_name()
EditorLevelLibrary.save_all_dirty_levels()
EditorLevelLibrary.load_level(layout_level)
@ -919,7 +919,7 @@ class LayoutLoader(plugin.Loader):
package_paths=[f"{root}/{ms_asset}"],
recursive_paths=False)
levels = ar.get_assets(_filter)
master_level = levels[0].get_full_name()
master_level = levels[0].get_asset().get_path_name()
sequences = [master_sequence]

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# flake8: noqa E402
"""Pype module API."""
"""OpenPype lib functions."""
# add vendor to sys path based on Python version
import sys
import os
@ -94,7 +94,8 @@ from .python_module_tools import (
modules_from_path,
recursive_bases_from_class,
classes_from_module,
import_module_from_dirpath
import_module_from_dirpath,
is_func_signature_supported,
)
from .profiles_filtering import (
@ -243,6 +244,7 @@ __all__ = [
"recursive_bases_from_class",
"classes_from_module",
"import_module_from_dirpath",
"is_func_signature_supported",
"get_transcode_temp_directory",
"should_convert_for_ffmpeg",

View file

@ -6,10 +6,9 @@ import inspect
import logging
import weakref
from uuid import uuid4
try:
from weakref import WeakMethod
except Exception:
from openpype.lib.python_2_comp import WeakMethod
from .python_2_comp import WeakMethod
from .python_module_tools import is_func_signature_supported
class MissingEventSystem(Exception):
@ -80,40 +79,8 @@ class EventCallback(object):
# Get expected arguments from function spec
# - positional arguments are always preferred
expect_args = False
expect_kwargs = False
fake_event = "fake"
if hasattr(inspect, "signature"):
# Python 3 using 'Signature' object where we try to bind arg
# or kwarg. Using signature is recommended approach based on
# documentation.
sig = inspect.signature(func)
try:
sig.bind(fake_event)
expect_args = True
except TypeError:
pass
try:
sig.bind(event=fake_event)
expect_kwargs = True
except TypeError:
pass
else:
# In Python 2 'signature' is not available so 'getcallargs' is used
# - 'getcallargs' is marked as deprecated since Python 3.0
try:
inspect.getcallargs(func, fake_event)
expect_args = True
except TypeError:
pass
try:
inspect.getcallargs(func, event=fake_event)
expect_kwargs = True
except TypeError:
pass
expect_args = is_func_signature_supported(func, "fake")
expect_kwargs = is_func_signature_supported(func, event="fake")
self._func_ref = func_ref
self._func_name = func_name

View file

@ -190,7 +190,7 @@ def run_openpype_process(*args, **kwargs):
Example:
```
run_openpype_process("run", "<path to .py script>")
run_detached_process("run", "<path to .py script>")
```
Args:

View file

@ -1,41 +1,44 @@
import weakref
class _weak_callable:
def __init__(self, obj, func):
self.im_self = obj
self.im_func = func
WeakMethod = getattr(weakref, "WeakMethod", None)
def __call__(self, *args, **kws):
if self.im_self is None:
return self.im_func(*args, **kws)
else:
return self.im_func(self.im_self, *args, **kws)
if WeakMethod is None:
class _WeakCallable:
def __init__(self, obj, func):
self.im_self = obj
self.im_func = func
def __call__(self, *args, **kws):
if self.im_self is None:
return self.im_func(*args, **kws)
else:
return self.im_func(self.im_self, *args, **kws)
class WeakMethod:
""" Wraps a function or, more importantly, a bound method in
a way that allows a bound method's object to be GCed, while
providing the same interface as a normal weak reference. """
class WeakMethod:
""" Wraps a function or, more importantly, a bound method in
a way that allows a bound method's object to be GCed, while
providing the same interface as a normal weak reference. """
def __init__(self, fn):
try:
self._obj = weakref.ref(fn.im_self)
self._meth = fn.im_func
except AttributeError:
# It's not a bound method
self._obj = None
self._meth = fn
def __init__(self, fn):
try:
self._obj = weakref.ref(fn.im_self)
self._meth = fn.im_func
except AttributeError:
# It's not a bound method
self._obj = None
self._meth = fn
def __call__(self):
if self._dead():
return None
return _weak_callable(self._getobj(), self._meth)
def __call__(self):
if self._dead():
return None
return _WeakCallable(self._getobj(), self._meth)
def _dead(self):
return self._obj is not None and self._obj() is None
def _dead(self):
return self._obj is not None and self._obj() is None
def _getobj(self):
if self._obj is None:
return None
return self._obj()
def _getobj(self):
if self._obj is None:
return None
return self._obj()

View file

@ -230,3 +230,70 @@ def import_module_from_dirpath(dirpath, folder_name, dst_module_name=None):
dirpath, folder_name, dst_module_name
)
return module
def is_func_signature_supported(func, *args, **kwargs):
"""Check if a function signature supports passed args and kwargs.
This check does not actually call the function, just look if function can
be called with the arguments.
Notes:
This does NOT check if the function would work with passed arguments
only if they can be passed in. If function have *args, **kwargs
in paramaters, this will always return 'True'.
Example:
>>> def my_function(my_number):
... return my_number + 1
...
>>> is_func_signature_supported(my_function, 1)
True
>>> is_func_signature_supported(my_function, 1, 2)
False
>>> is_func_signature_supported(my_function, my_number=1)
True
>>> is_func_signature_supported(my_function, number=1)
False
>>> is_func_signature_supported(my_function, "string")
True
>>> def my_other_function(*args, **kwargs):
... my_function(*args, **kwargs)
...
>>> is_func_signature_supported(
... my_other_function,
... "string",
... 1,
... other=None
... )
True
Args:
func (function): A function where the signature should be tested.
*args (tuple[Any]): Positional arguments for function signature.
**kwargs (dict[str, Any]): Keyword arguments for function signature.
Returns:
bool: Function can pass in arguments.
"""
if hasattr(inspect, "signature"):
# Python 3 using 'Signature' object where we try to bind arg
# or kwarg. Using signature is recommended approach based on
# documentation.
sig = inspect.signature(func)
try:
sig.bind(*args, **kwargs)
return True
except TypeError:
pass
else:
# In Python 2 'signature' is not available so 'getcallargs' is used
# - 'getcallargs' is marked as deprecated since Python 3.0
try:
inspect.getcallargs(func, *args, **kwargs)
return True
except TypeError:
pass
return False

View file

@ -4,7 +4,18 @@ import pyblish.api
class CollectDefaultDeadlineServer(pyblish.api.ContextPlugin):
"""Collect default Deadline Webservice URL."""
"""Collect default Deadline Webservice URL.
DL webservice addresses must be configured first in System Settings for
project settings enum to work.
Default webservice could be overriden by
`project_settings/deadline/deadline_servers`. Currently only single url
is expected.
This url could be overriden by some hosts directly on instances with
`CollectDeadlineServerFromInstance`.
"""
order = pyblish.api.CollectorOrder + 0.410
label = "Default Deadline Webservice"
@ -23,3 +34,16 @@ class CollectDefaultDeadlineServer(pyblish.api.ContextPlugin):
context.data["defaultDeadline"] = deadline_module.deadline_urls["default"] # noqa: E501
context.data["deadlinePassMongoUrl"] = self.pass_mongo_url
deadline_servers = (context.data
["project_settings"]
["deadline"]
["deadline_servers"])
if deadline_servers:
deadline_server_name = deadline_servers[0]
deadline_webservice = deadline_module.deadline_urls.get(
deadline_server_name)
if deadline_webservice:
context.data["defaultDeadline"] = deadline_webservice
self.log.debug("Overriding from project settings with {}".format( # noqa: E501
deadline_webservice))

View file

@ -73,7 +73,7 @@ class FusionSubmitDeadline(
def process(self, instance):
if not instance.data.get("farm"):
self.log.info("Skipping local instance.")
self.log.debug("Skipping local instance.")
return
attribute_values = self.get_attr_values_from_data(

View file

@ -86,7 +86,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin,
def process(self, instance):
if not instance.data.get("farm"):
self.log.info("Skipping local instance.")
self.log.debug("Skipping local instance.")
return
instance.data["attributeValues"] = self.get_attr_values_from_data(

View file

@ -762,7 +762,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
"""
if not instance.data.get("farm"):
self.log.info("Skipping local instance.")
self.log.debug("Skipping local instance.")
return
data = instance.data.copy()

View file

@ -26,7 +26,7 @@ class ValidateDeadlinePools(OptionalPyblishPluginMixin,
def process(self, instance):
if not instance.data.get("farm"):
self.log.info("Skipping local instance.")
self.log.debug("Skipping local instance.")
return
# get default deadline webservice url from deadline module

View file

@ -167,16 +167,25 @@ class AbstractCollectRender(pyblish.api.ContextPlugin):
frame_start_render = int(render_instance.frameStart)
frame_end_render = int(render_instance.frameEnd)
# TODO: Refactor hacky frame range workaround below
if (render_instance.ignoreFrameHandleCheck or
int(context.data['frameStartHandle']) == frame_start_render
and int(context.data['frameEndHandle']) == frame_end_render): # noqa: W503, E501
# only for Harmony where frame range cannot be set by DB
handle_start = context.data['handleStart']
handle_end = context.data['handleEnd']
frame_start = context.data['frameStart']
frame_end = context.data['frameEnd']
frame_start_handle = context.data['frameStartHandle']
frame_end_handle = context.data['frameEndHandle']
elif (hasattr(render_instance, "frameStartHandle")
and hasattr(render_instance, "frameEndHandle")):
handle_start = int(render_instance.handleStart)
handle_end = int(render_instance.handleEnd)
frame_start = int(render_instance.frameStart)
frame_end = int(render_instance.frameEnd)
frame_start_handle = int(render_instance.frameStartHandle)
frame_end_handle = int(render_instance.frameEndHandle)
else:
handle_start = 0
handle_end = 0

View file

@ -1,12 +1,10 @@
import os
import sys
import types
import inspect
import copy
import tempfile
import xml.etree.ElementTree
import six
import pyblish.util
import pyblish.plugin
import pyblish.api
@ -42,7 +40,9 @@ def get_template_name_profiles(
Args:
project_name (str): Name of project where to look for templates.
project_settings(Dic[str, Any]): Prepared project settings.
project_settings (Dict[str, Any]): Prepared project settings.
logger (Optional[logging.Logger]): Logger object to be used instead
of default logger.
Returns:
List[Dict[str, Any]]: Publish template profiles.
@ -103,7 +103,9 @@ def get_hero_template_name_profiles(
Args:
project_name (str): Name of project where to look for templates.
project_settings(Dic[str, Any]): Prepared project settings.
project_settings (Dict[str, Any]): Prepared project settings.
logger (Optional[logging.Logger]): Logger object to be used instead
of default logger.
Returns:
List[Dict[str, Any]]: Publish template profiles.
@ -172,9 +174,10 @@ def get_publish_template_name(
project_name (str): Name of project where to look for settings.
host_name (str): Name of host integration.
family (str): Family for which should be found template.
task_name (str): Task name on which is intance working.
task_type (str): Task type on which is intance working.
project_setting (Dict[str, Any]): Prepared project settings.
task_name (str): Task name on which is instance working.
task_type (str): Task type on which is instance working.
project_settings (Dict[str, Any]): Prepared project settings.
hero (bool): Template is for hero version publishing.
logger (logging.Logger): Custom logger used for 'filter_profiles'
function.
@ -264,19 +267,18 @@ def load_help_content_from_plugin(plugin):
def publish_plugins_discover(paths=None):
"""Find and return available pyblish plug-ins
Overridden function from `pyblish` module to be able collect crashed files
and reason of their crash.
Overridden function from `pyblish` module to be able to collect
crashed files and reason of their crash.
Arguments:
paths (list, optional): Paths to discover plug-ins from.
If no paths are provided, all paths are searched.
"""
# The only difference with `pyblish.api.discover`
result = DiscoverResult(pyblish.api.Plugin)
plugins = dict()
plugins = {}
plugin_names = []
allow_duplicates = pyblish.plugin.ALLOW_DUPLICATES
@ -302,7 +304,7 @@ def publish_plugins_discover(paths=None):
mod_name, mod_ext = os.path.splitext(fname)
if not mod_ext == ".py":
if mod_ext != ".py":
continue
try:
@ -320,6 +322,14 @@ def publish_plugins_discover(paths=None):
continue
for plugin in pyblish.plugin.plugins_from_module(module):
# Ignore base plugin classes
# NOTE 'pyblish.api.discover' does not ignore them!
if (
plugin is pyblish.api.Plugin
or plugin is pyblish.api.ContextPlugin
or plugin is pyblish.api.InstancePlugin
):
continue
if not allow_duplicates and plugin.__name__ in plugin_names:
result.duplicated_plugins.append(plugin)
log.debug("Duplicate plug-in found: %s", plugin)
@ -525,10 +535,10 @@ def find_close_plugin(close_plugin_name, log):
def remote_publish(log, close_plugin_name=None, raise_error=False):
"""Loops through all plugins, logs to console. Used for tests.
Args:
log (openpype.lib.Logger)
close_plugin_name (str): name of plugin with responsibility to
close host app
Args:
log (Logger)
close_plugin_name (str): name of plugin with responsibility to
close host app
"""
# Error exit as soon as any error occurs.
error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}"
@ -837,3 +847,22 @@ def _validate_transient_template(project_name, template_name, anatomy):
raise ValueError(("There is not set \"folder\" template in \"{}\" anatomy" # noqa
" for project \"{}\"."
).format(template_name, project_name))
def add_repre_files_for_cleanup(instance, repre):
""" Explicitly mark repre files to be deleted.
Should be used on intermediate files (eg. review, thumbnails) to be
explicitly deleted.
"""
files = repre["files"]
staging_dir = repre.get("stagingDir")
if not staging_dir:
return
if isinstance(files, str):
files = [files]
for file_name in files:
expected_file = os.path.join(staging_dir, file_name)
instance.context.data["cleanupFullPaths"].append(expected_file)

View file

@ -379,7 +379,9 @@ class ColormanagedPyblishPluginMixin(object):
# check if ext in lower case is in self.allowed_ext
if ext.lstrip(".").lower() not in self.allowed_ext:
self.log.debug("Extension is not in allowed extensions.")
self.log.debug(
"Extension '{}' is not in allowed extensions.".format(ext)
)
return
if colorspace_settings is None:
@ -393,8 +395,7 @@ class ColormanagedPyblishPluginMixin(object):
self.log.warning("No colorspace management was defined")
return
self.log.info("Config data is : `{}`".format(
config_data))
self.log.debug("Config data is: `{}`".format(config_data))
project_name = context.data["projectName"]
host_name = context.data["hostName"]
@ -405,8 +406,7 @@ class ColormanagedPyblishPluginMixin(object):
if isinstance(filename, list):
filename = filename[0]
self.log.debug("__ filename: `{}`".format(
filename))
self.log.debug("__ filename: `{}`".format(filename))
# get matching colorspace from rules
colorspace = colorspace or get_imageio_colorspace_from_filepath(
@ -415,8 +415,7 @@ class ColormanagedPyblishPluginMixin(object):
file_rules=file_rules,
project_settings=project_settings
)
self.log.debug("__ colorspace: `{}`".format(
colorspace))
self.log.debug("__ colorspace: `{}`".format(colorspace))
# infuse data to representation
if colorspace:

View file

@ -81,7 +81,8 @@ class CleanUp(pyblish.api.InstancePlugin):
staging_dir = instance.data.get("stagingDir", None)
if not staging_dir:
self.log.info("Staging dir not set.")
self.log.debug("Skipping cleanup. Staging dir not set "
"on instance: {}.".format(instance))
return
if not os.path.normpath(staging_dir).startswith(temp_root):
@ -90,7 +91,7 @@ class CleanUp(pyblish.api.InstancePlugin):
return
if not os.path.exists(staging_dir):
self.log.info("No staging directory found: %s" % staging_dir)
self.log.debug("No staging directory found at: %s" % staging_dir)
return
if instance.data.get("stagingDir_persistent"):
@ -131,7 +132,9 @@ class CleanUp(pyblish.api.InstancePlugin):
try:
os.remove(src)
except PermissionError:
self.log.warning("Insufficient permission to delete {}".format(src))
self.log.warning(
"Insufficient permission to delete {}".format(src)
)
continue
# add dir for cleanup

View file

@ -67,5 +67,6 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin):
# Store
context.data["anatomyData"] = anatomy_data
self.log.info("Global anatomy Data collected")
self.log.debug(json.dumps(anatomy_data, indent=4))
self.log.debug("Global Anatomy Context Data collected:\n{}".format(
json.dumps(anatomy_data, indent=4)
))

View file

@ -46,17 +46,17 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
follow_workfile_version = False
def process(self, context):
self.log.info("Collecting anatomy data for all instances.")
self.log.debug("Collecting anatomy data for all instances.")
project_name = context.data["projectName"]
self.fill_missing_asset_docs(context, project_name)
self.fill_latest_versions(context, project_name)
self.fill_anatomy_data(context)
self.log.info("Anatomy Data collection finished.")
self.log.debug("Anatomy Data collection finished.")
def fill_missing_asset_docs(self, context, project_name):
self.log.debug("Qeurying asset documents for instances.")
self.log.debug("Querying asset documents for instances.")
context_asset_doc = context.data.get("assetEntity")
@ -271,7 +271,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
instance_name = instance.data["name"]
instance_label = instance.data.get("label")
if instance_label:
instance_name += "({})".format(instance_label)
instance_name += " ({})".format(instance_label)
self.log.debug("Anatomy data for instance {}: {}".format(
instance_name,
json.dumps(anatomy_data, indent=4)

View file

@ -30,6 +30,6 @@ class CollectAnatomyObject(pyblish.api.ContextPlugin):
context.data["anatomy"] = Anatomy(project_name)
self.log.info(
self.log.debug(
"Anatomy object collected for project \"{}\".".format(project_name)
)

View file

@ -65,6 +65,6 @@ class CollectCustomStagingDir(pyblish.api.InstancePlugin):
else:
result_str = "Not adding"
self.log.info("{} custom staging dir for instance with '{}'".format(
self.log.debug("{} custom staging dir for instance with '{}'".format(
result_str, family
))

View file

@ -92,5 +92,5 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin):
instance.data["transientData"] = transient_data
self.log.info("collected instance: {}".format(instance.data))
self.log.info("parsing data: {}".format(in_data))
self.log.debug("collected instance: {}".format(instance.data))
self.log.debug("parsing data: {}".format(in_data))

View file

@ -13,6 +13,7 @@ import json
import pyblish.api
from openpype.pipeline import legacy_io, KnownPublishError
from openpype.pipeline.publish.lib import add_repre_files_for_cleanup
class CollectRenderedFiles(pyblish.api.ContextPlugin):
@ -89,6 +90,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
# now we can just add instances from json file and we are done
for instance_data in data.get("instances"):
self.log.info(" - processing instance for {}".format(
instance_data.get("subset")))
instance = self._context.create_instance(
@ -107,6 +109,8 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
self._fill_staging_dir(repre_data, anatomy)
representations.append(repre_data)
add_repre_files_for_cleanup(instance, repre_data)
instance.data["representations"] = representations
# add audio if in metadata data
@ -157,6 +161,8 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
os.environ.update(session_data)
session_is_set = True
self._process_path(data, anatomy)
context.data["cleanupFullPaths"].append(path)
context.data["cleanupEmptyDirs"].append(os.path.dirname(path))
except Exception as e:
self.log.error(e, exc_info=True)
raise Exception("Error") from e

View file

@ -48,10 +48,13 @@ class CollectSceneVersion(pyblish.api.ContextPlugin):
if '<shell>' in filename:
return
self.log.debug(
"Collecting scene version from filename: {}".format(filename)
)
version = get_version_from_path(filename)
assert version, "Cannot determine version"
rootVersion = int(version)
context.data['version'] = rootVersion
self.log.info("{}".format(type(rootVersion)))
self.log.info('Scene Version: %s' % context.data.get('version'))

View file

@ -19,6 +19,7 @@ from openpype.lib import (
should_convert_for_ffmpeg
)
from openpype.lib.profiles_filtering import filter_profiles
from openpype.pipeline.publish.lib import add_repre_files_for_cleanup
class ExtractBurnin(publish.Extractor):
@ -353,6 +354,8 @@ class ExtractBurnin(publish.Extractor):
# Add new representation to instance
instance.data["representations"].append(new_repre)
add_repre_files_for_cleanup(instance, new_repre)
# Cleanup temp staging dir after procesisng of output definitions
if do_convert:
temp_dir = repre["stagingDir"]
@ -517,8 +520,8 @@ class ExtractBurnin(publish.Extractor):
"""
if "burnin" not in (repre.get("tags") or []):
self.log.info((
"Representation \"{}\" don't have \"burnin\" tag. Skipped."
self.log.debug((
"Representation \"{}\" does not have \"burnin\" tag. Skipped."
).format(repre["name"]))
return False

View file

@ -336,13 +336,13 @@ class ExtractOIIOTranscode(publish.Extractor):
if repre.get("ext") not in self.supported_exts:
self.log.debug((
"Representation '{}' of unsupported extension. Skipped."
).format(repre["name"]))
"Representation '{}' has unsupported extension: '{}'. Skipped."
).format(repre["name"], repre.get("ext")))
return False
if not repre.get("files"):
self.log.debug((
"Representation '{}' have empty files. Skipped."
"Representation '{}' has empty files. Skipped."
).format(repre["name"]))
return False

View file

@ -24,6 +24,7 @@ from openpype.lib.transcoding import (
get_transcode_temp_directory,
)
from openpype.pipeline.publish import KnownPublishError
from openpype.pipeline.publish.lib import add_repre_files_for_cleanup
class ExtractReview(pyblish.api.InstancePlugin):
@ -92,8 +93,8 @@ class ExtractReview(pyblish.api.InstancePlugin):
host_name = instance.context.data["hostName"]
family = self.main_family_from_instance(instance)
self.log.info("Host: \"{}\"".format(host_name))
self.log.info("Family: \"{}\"".format(family))
self.log.debug("Host: \"{}\"".format(host_name))
self.log.debug("Family: \"{}\"".format(family))
profile = filter_profiles(
self.profiles,
@ -351,7 +352,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
temp_data = self.prepare_temp_data(instance, repre, output_def)
files_to_clean = []
if temp_data["input_is_sequence"]:
self.log.info("Filling gaps in sequence.")
self.log.debug("Checking sequence to fill gaps in sequence..")
files_to_clean = self.fill_sequence_gaps(
files=temp_data["origin_repre"]["files"],
staging_dir=new_repre["stagingDir"],
@ -425,6 +426,8 @@ class ExtractReview(pyblish.api.InstancePlugin):
)
instance.data["representations"].append(new_repre)
add_repre_files_for_cleanup(instance, new_repre)
def input_is_sequence(self, repre):
"""Deduce from representation data if input is sequence."""
# TODO GLOBAL ISSUE - Find better way how to find out if input

View file

@ -36,7 +36,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
).format(subset_name))
return
self.log.info(
self.log.debug(
"Processing instance with subset name {}".format(subset_name)
)
@ -89,13 +89,13 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
src_staging = os.path.normpath(repre["stagingDir"])
full_input_path = os.path.join(src_staging, input_file)
self.log.info("input {}".format(full_input_path))
self.log.debug("input {}".format(full_input_path))
filename = os.path.splitext(input_file)[0]
jpeg_file = filename + "_thumb.jpg"
full_output_path = os.path.join(dst_staging, jpeg_file)
if oiio_supported:
self.log.info("Trying to convert with OIIO")
self.log.debug("Trying to convert with OIIO")
# If the input can read by OIIO then use OIIO method for
# conversion otherwise use ffmpeg
thumbnail_created = self.create_thumbnail_oiio(
@ -148,7 +148,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
def _already_has_thumbnail(self, repres):
for repre in repres:
self.log.info("repre {}".format(repre))
self.log.debug("repre {}".format(repre))
if repre["name"] == "thumbnail":
return True
return False
@ -173,20 +173,20 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
return filtered_repres
def create_thumbnail_oiio(self, src_path, dst_path):
self.log.info("outputting {}".format(dst_path))
self.log.info("Extracting thumbnail {}".format(dst_path))
oiio_tool_path = get_oiio_tools_path()
oiio_cmd = [
oiio_tool_path,
"-a", src_path,
"-o", dst_path
]
self.log.info("running: {}".format(" ".join(oiio_cmd)))
self.log.debug("running: {}".format(" ".join(oiio_cmd)))
try:
run_subprocess(oiio_cmd, logger=self.log)
return True
except Exception:
self.log.warning(
"Failed to create thubmnail using oiiotool",
"Failed to create thumbnail using oiiotool",
exc_info=True
)
return False

View file

@ -39,7 +39,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
self._create_context_thumbnail(instance.context)
subset_name = instance.data["subset"]
self.log.info(
self.log.debug(
"Processing instance with subset name {}".format(subset_name)
)
thumbnail_source = instance.data.get("thumbnailSource")
@ -104,7 +104,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
full_output_path = os.path.join(dst_staging, dst_filename)
if oiio_supported:
self.log.info("Trying to convert with OIIO")
self.log.debug("Trying to convert with OIIO")
# If the input can read by OIIO then use OIIO method for
# conversion otherwise use ffmpeg
thumbnail_created = self.create_thumbnail_oiio(

View file

@ -267,7 +267,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
instance_stagingdir = instance.data.get("stagingDir")
if not instance_stagingdir:
self.log.info((
self.log.debug((
"{0} is missing reference to staging directory."
" Will try to get it from representation."
).format(instance))
@ -480,7 +480,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
update_data
)
self.log.info("Prepared subset: {}".format(subset_name))
self.log.debug("Prepared subset: {}".format(subset_name))
return subset_doc
def prepare_version(self, instance, op_session, subset_doc, project_name):
@ -521,7 +521,9 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
project_name, version_doc["type"], version_doc
)
self.log.info("Prepared version: v{0:03d}".format(version_doc["name"]))
self.log.debug(
"Prepared version: v{0:03d}".format(version_doc["name"])
)
return version_doc

View file

@ -147,7 +147,9 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
def process(self, instance):
if instance.data.get("processedWithNewIntegrator"):
self.log.info("Instance was already processed with new integrator")
self.log.debug(
"Instance was already processed with new integrator"
)
return
for ef in self.exclude_families:
@ -274,7 +276,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
stagingdir = instance.data.get("stagingDir")
if not stagingdir:
self.log.info((
self.log.debug((
"{0} is missing reference to staging directory."
" Will try to get it from representation."
).format(instance))

View file

@ -41,7 +41,7 @@ class IntegrateThumbnails(pyblish.api.ContextPlugin):
# Filter instances which can be used for integration
filtered_instance_items = self._prepare_instances(context)
if not filtered_instance_items:
self.log.info(
self.log.debug(
"All instances were filtered. Thumbnail integration skipped."
)
return
@ -162,7 +162,7 @@ class IntegrateThumbnails(pyblish.api.ContextPlugin):
# Skip instance if thumbnail path is not available for it
if not thumbnail_path:
self.log.info((
self.log.debug((
"Skipping thumbnail integration for instance \"{}\"."
" Instance and context"
" thumbnail paths are not available."

View file

@ -358,12 +358,12 @@
"optional": true,
"active": true
},
"ValidateGizmo": {
"ValidateBackdrop": {
"enabled": true,
"optional": true,
"active": true
},
"ValidateBackdrop": {
"ValidateGizmo": {
"enabled": true,
"optional": true,
"active": true
@ -401,7 +401,39 @@
false
]
]
}
},
"reposition_nodes": [
{
"node_class": "Reformat",
"knobs": [
{
"type": "text",
"name": "type",
"value": "to format"
},
{
"type": "text",
"name": "format",
"value": "HD_1080"
},
{
"type": "text",
"name": "filter",
"value": "Lanczos6"
},
{
"type": "bool",
"name": "black_outside",
"value": true
},
{
"type": "bool",
"name": "pbb",
"value": false
}
]
}
]
},
"ExtractReviewData": {
"enabled": false

View file

@ -323,7 +323,10 @@ class SchemasHub:
filled_template = self._fill_template(
schema_data, template_def
)
return filled_template
new_template_def = []
for item in filled_template:
new_template_def.extend(self.resolve_schema_data(item))
return new_template_def
def create_schema_object(self, schema_data, *args, **kwargs):
"""Create entity for passed schema data.

View file

@ -158,10 +158,43 @@
"label": "Nodes",
"collapsible": true,
"children": [
{
"type": "label",
"label": "Nodes attribute will be deprecated in future releases. Use reposition_nodes instead."
},
{
"type": "raw-json",
"key": "nodes",
"label": "Nodes"
"label": "Nodes [depricated]"
},
{
"type": "label",
"label": "Reposition knobs supported only. You can add multiple reformat nodes <br/>and set their knobs. Order of reformat nodes is important. First reformat node <br/>will be applied first and last reformat node will be applied last."
},
{
"key": "reposition_nodes",
"type": "list",
"label": "Reposition nodes",
"object_type": {
"type": "dict",
"children": [
{
"key": "node_class",
"label": "Node class",
"type": "text"
},
{
"type": "schema_template",
"name": "template_nuke_knob_inputs",
"template_data": [
{
"label": "Node knobs",
"key": "knobs"
}
]
}
]
}
}
]
}

View file

@ -26,8 +26,8 @@
"bg": "#2C313A",
"bg-inputs": "#21252B",
"bg-buttons": "#434a56",
"bg-button-hover": "rgb(81, 86, 97)",
"bg-buttons": "rgb(67, 74, 86)",
"bg-buttons-hover": "rgb(81, 86, 97)",
"bg-inputs-disabled": "#2C313A",
"bg-buttons-disabled": "#434a56",
@ -66,7 +66,9 @@
"bg-success": "#458056",
"bg-success-hover": "#55a066",
"bg-error": "#AD2E2E",
"bg-error-hover": "#C93636"
"bg-error-hover": "#C93636",
"bg-info": "rgb(63, 98, 121)",
"bg-info-hover": "rgb(81, 146, 181)"
},
"tab-widget": {
"bg": "#21252B",
@ -94,6 +96,7 @@
"crash": "#FF6432",
"success": "#458056",
"warning": "#ffc671",
"progress": "rgb(194, 226, 236)",
"tab-bg": "#16191d",
"list-view-group": {
"bg": "#434a56",

View file

@ -136,7 +136,7 @@ QPushButton {
}
QPushButton:hover {
background: {color:bg-button-hover};
background: {color:bg-buttons-hover};
color: {color:font-hover};
}
@ -166,7 +166,7 @@ QToolButton {
}
QToolButton:hover {
background: {color:bg-button-hover};
background: {color:bg-buttons-hover};
color: {color:font-hover};
}
@ -722,6 +722,13 @@ OverlayMessageWidget[type="error"]:hover {
background: {color:overlay-messages:bg-error-hover};
}
OverlayMessageWidget[type="info"] {
background: {color:overlay-messages:bg-info};
}
OverlayMessageWidget[type="info"]:hover {
background: {color:overlay-messages:bg-info-hover};
}
OverlayMessageWidget QWidget {
background: transparent;
}
@ -749,10 +756,11 @@ OverlayMessageWidget QWidget {
}
#InfoText {
padding-left: 30px;
padding-top: 20px;
padding-left: 0px;
padding-top: 0px;
padding-right: 20px;
background: transparent;
border: 1px solid {color:border};
border: none;
}
#TypeEditor, #ToolEditor, #NameEditor, #NumberEditor {
@ -914,7 +922,7 @@ PixmapButton{
background: {color:bg-buttons};
}
PixmapButton:hover {
background: {color:bg-button-hover};
background: {color:bg-buttons-hover};
}
PixmapButton:disabled {
background: {color:bg-buttons-disabled};
@ -925,7 +933,7 @@ PixmapButton:disabled {
background: {color:bg-view};
}
#ThumbnailPixmapHoverButton:hover {
background: {color:bg-button-hover};
background: {color:bg-buttons-hover};
}
#CreatorDetailedDescription {
@ -946,7 +954,7 @@ PixmapButton:disabled {
}
#CreateDialogHelpButton:hover {
background: {color:bg-button-hover};
background: {color:bg-buttons-hover};
}
#CreateDialogHelpButton QWidget {
background: transparent;
@ -1005,7 +1013,7 @@ PixmapButton:disabled {
border-radius: 0.2em;
}
#CardViewWidget:hover {
background: {color:bg-button-hover};
background: {color:bg-buttons-hover};
}
#CardViewWidget[state="selected"] {
background: {color:bg-view-selection};
@ -1032,7 +1040,7 @@ PixmapButton:disabled {
}
#PublishInfoFrame[state="3"], #PublishInfoFrame[state="4"] {
background: rgb(194, 226, 236);
background: {color:publisher:progress};
}
#PublishInfoFrame QLabel {
@ -1040,6 +1048,11 @@ PixmapButton:disabled {
font-style: bold;
}
#PublishReportHeader {
font-size: 14pt;
font-weight: bold;
}
#PublishInfoMainLabel {
font-size: 12pt;
}
@ -1060,7 +1073,7 @@ ValidationArtistMessage QLabel {
}
#ValidationActionButton:hover {
background: {color:bg-button-hover};
background: {color:bg-buttons-hover};
color: {color:font-hover};
}
@ -1090,6 +1103,35 @@ ValidationArtistMessage QLabel {
border-left: 1px solid {color:border};
}
#PublishInstancesDetails {
border: 1px solid {color:border};
border-radius: 0.3em;
}
#InstancesLogsView {
border: 1px solid {color:border};
background: {color:bg-view};
border-radius: 0.3em;
}
#PublishLogMessage {
font-family: "Noto Sans Mono";
}
#PublishInstanceLogsLabel {
font-weight: bold;
}
#PublishCrashMainLabel{
font-weight: bold;
font-size: 16pt;
}
#PublishCrashReportLabel {
font-weight: bold;
font-size: 13pt;
}
#AssetNameInputWidget {
background: {color:bg-inputs};
border: 1px solid {color:border};

View file

@ -198,29 +198,33 @@ class DropEmpty(QtWidgets.QWidget):
def paintEvent(self, event):
super(DropEmpty, self).paintEvent(event)
painter = QtGui.QPainter(self)
pen = QtGui.QPen()
pen.setWidth(1)
pen.setBrush(QtCore.Qt.darkGray)
pen.setStyle(QtCore.Qt.DashLine)
painter.setPen(pen)
content_margins = self.layout().contentsMargins()
pen.setWidth(1)
left_m = content_margins.left()
top_m = content_margins.top()
rect = QtCore.QRect(
content_margins = self.layout().contentsMargins()
rect = self.rect()
left_m = content_margins.left() + pen.width()
top_m = content_margins.top() + pen.width()
new_rect = QtCore.QRect(
left_m,
top_m,
(
self.rect().width()
rect.width()
- (left_m + content_margins.right() + pen.width())
),
(
self.rect().height()
rect.height()
- (top_m + content_margins.bottom() + pen.width())
)
)
painter.drawRect(rect)
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.Antialiasing)
painter.setPen(pen)
painter.drawRect(new_rect)
class FilesModel(QtGui.QStandardItemModel):

View file

@ -35,9 +35,13 @@ ResetKeySequence = QtGui.QKeySequence(
__all__ = (
"CONTEXT_ID",
"CONTEXT_LABEL",
"VARIANT_TOOLTIP",
"INPUTS_LAYOUT_HSPACING",
"INPUTS_LAYOUT_VSPACING",
"INSTANCE_ID_ROLE",
"SORT_VALUE_ROLE",
"IS_GROUP_ROLE",
@ -47,4 +51,6 @@ __all__ = (
"FAMILY_ROLE",
"GROUP_ROLE",
"CONVERTER_IDENTIFIER_ROLE",
"ResetKeySequence",
)

View file

@ -47,6 +47,7 @@ PLUGIN_ORDER_OFFSET = 0.5
class CardMessageTypes:
standard = None
info = "info"
error = "error"
@ -220,7 +221,12 @@ class PublishReportMaker:
def _add_plugin_data_item(self, plugin):
if plugin in self._stored_plugins:
raise ValueError("Plugin is already stored")
# A plugin would be processed more than once. What can cause it:
# - there is a bug in controller
# - plugin class is imported into multiple files
# - this can happen even with base classes from 'pyblish'
raise ValueError(
"Plugin '{}' is already stored".format(str(plugin)))
self._stored_plugins.append(plugin)
@ -239,6 +245,7 @@ class PublishReportMaker:
label = plugin.label
return {
"id": plugin.id,
"name": plugin.__name__,
"label": label,
"order": plugin.order,
@ -324,7 +331,7 @@ class PublishReportMaker:
"instances": instances_details,
"context": self._extract_context_data(self._current_context),
"crashed_file_paths": crashed_file_paths,
"id": str(uuid.uuid4()),
"id": uuid.uuid4().hex,
"report_version": "1.0.0"
}
@ -342,7 +349,9 @@ class PublishReportMaker:
"label": instance.data.get("label"),
"family": instance.data["family"],
"families": instance.data.get("families") or [],
"exists": exists
"exists": exists,
"creator_identifier": instance.data.get("creator_identifier"),
"instance_id": instance.data.get("instance_id"),
}
def _extract_instance_log_items(self, result):
@ -388,8 +397,11 @@ class PublishReportMaker:
exception = result.get("error")
if exception:
fname, line_no, func, exc = exception.traceback
# Action result does not have 'is_validation_error'
is_validation_error = result.get("is_validation_error", False)
output.append({
"type": "error",
"is_validation_error": is_validation_error,
"msg": str(exception),
"filename": str(fname),
"lineno": str(line_no),
@ -426,13 +438,15 @@ class PublishPluginsProxy:
plugin_id = plugin.id
plugins_by_id[plugin_id] = plugin
action_ids = set()
action_ids = []
action_ids_by_plugin_id[plugin_id] = action_ids
actions = getattr(plugin, "actions", None) or []
for action in actions:
action_id = action.id
action_ids.add(action_id)
if action_id in actions_by_id:
continue
action_ids.append(action_id)
actions_by_id[action_id] = action
self._plugins_by_id = plugins_by_id
@ -461,7 +475,7 @@ class PublishPluginsProxy:
return plugin.id
def get_plugin_action_items(self, plugin_id):
"""Get plugin action items for plugin by it's id.
"""Get plugin action items for plugin by its id.
Args:
plugin_id (str): Publish plugin id.
@ -568,7 +582,7 @@ class ValidationErrorItem:
context_validation,
title,
description,
detail,
detail
):
self.instance_id = instance_id
self.instance_label = instance_label
@ -677,6 +691,8 @@ class PublishValidationErrorsReport:
for title in titles:
grouped_error_items.append({
"id": uuid.uuid4().hex,
"plugin_id": plugin_id,
"plugin_action_items": list(plugin_action_items),
"error_items": error_items_by_title[title],
"title": title
@ -2379,7 +2395,8 @@ class PublisherController(BasePublisherController):
yield MainThreadItem(self.stop_publish)
# Add plugin to publish report
self._publish_report.add_plugin_iter(plugin, self._publish_context)
self._publish_report.add_plugin_iter(
plugin, self._publish_context)
# WARNING This is hack fix for optional plugins
if not self._is_publish_plugin_active(plugin):
@ -2461,14 +2478,14 @@ class PublisherController(BasePublisherController):
plugin, self._publish_context, instance
)
self._publish_report.add_result(result)
exception = result.get("error")
if exception:
has_validation_error = False
if (
isinstance(exception, PublishValidationError)
and not self.publish_has_validated
):
has_validation_error = True
self._add_validation_error(result)
else:
@ -2482,6 +2499,10 @@ class PublisherController(BasePublisherController):
self.publish_error_msg = msg
self.publish_has_crashed = True
result["is_validation_error"] = has_validation_error
self._publish_report.add_result(result)
self._publish_next_process()

View file

@ -163,7 +163,11 @@ class ZoomPlainText(QtWidgets.QPlainTextEdit):
super(ZoomPlainText, self).wheelEvent(event)
return
degrees = float(event.delta()) / 8
if hasattr(event, "angleDelta"):
delta = event.angleDelta().y()
else:
delta = event.delta()
degrees = float(delta) / 8
steps = int(ceil(degrees / 5))
self._scheduled_scalings += steps
if (self._scheduled_scalings * steps < 0):

View file

@ -18,7 +18,7 @@ from .help_widget import (
from .publish_frame import PublishFrame
from .tabs_widget import PublisherTabsWidget
from .overview_widget import OverviewWidget
from .validations_widget import ValidationsWidget
from .report_page import ReportPageWidget
__all__ = (
@ -40,5 +40,5 @@ __all__ = (
"PublisherTabsWidget",
"OverviewWidget",
"ValidationsWidget",
"ReportPageWidget",
)

View file

@ -93,7 +93,7 @@ class BaseGroupWidget(QtWidgets.QWidget):
return self._group
def get_widget_by_item_id(self, item_id):
"""Get instance widget by it's id."""
"""Get instance widget by its id."""
return self._widgets_by_id.get(item_id)
@ -702,8 +702,8 @@ class InstanceCardView(AbstractInstanceView):
for group_name in sorted_group_names:
group_icons = {
idenfier: self._controller.get_creator_icon(idenfier)
for idenfier in identifiers_by_group[group_name]
identifier: self._controller.get_creator_icon(identifier)
for identifier in identifiers_by_group[group_name]
}
if group_name in self._widgets_by_group:
group_widget = self._widgets_by_group[group_name]

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

View file

@ -468,45 +468,14 @@ class PublishFrame(QtWidgets.QWidget):
widget.setProperty("state", state)
widget.style().polish(widget)
def _copy_report(self):
logs = self._controller.get_publish_report()
logs_string = json.dumps(logs, indent=4)
mime_data = QtCore.QMimeData()
mime_data.setText(logs_string)
QtWidgets.QApplication.instance().clipboard().setMimeData(
mime_data
)
def _export_report(self):
default_filename = "publish-report-{}".format(
time.strftime("%y%m%d-%H-%M")
)
default_filepath = os.path.join(
os.path.expanduser("~"),
default_filename
)
new_filepath, ext = QtWidgets.QFileDialog.getSaveFileName(
self, "Save report", default_filepath, ".json"
)
if not ext or not new_filepath:
return
logs = self._controller.get_publish_report()
full_path = new_filepath + ext
dir_path = os.path.dirname(full_path)
if not os.path.exists(dir_path):
os.makedirs(dir_path)
with open(full_path, "w") as file_stream:
json.dump(logs, file_stream)
def _on_report_triggered(self, identifier):
if identifier == "export_report":
self._export_report()
self._controller.event_system.emit(
"export_report.request", {}, "publish_frame")
elif identifier == "copy_report":
self._copy_report()
self._controller.event_system.emit(
"copy_report.request", {}, "publish_frame")
elif identifier == "go_to_report":
self.details_page_requested.emit()

File diff suppressed because it is too large Load diff

View file

@ -75,6 +75,7 @@ class ThumbnailPainterWidget(QtWidgets.QWidget):
painter = QtGui.QPainter()
painter.begin(self)
painter.setRenderHint(QtGui.QPainter.Antialiasing)
painter.drawPixmap(0, 0, self._cached_pix)
painter.end()
@ -183,6 +184,18 @@ class ThumbnailPainterWidget(QtWidgets.QWidget):
backgrounded_images.append(new_pix)
return backgrounded_images
def _paint_dash_line(self, painter, rect):
pen = QtGui.QPen()
pen.setWidth(1)
pen.setBrush(QtCore.Qt.darkGray)
pen.setStyle(QtCore.Qt.DashLine)
new_rect = rect.adjusted(1, 1, -1, -1)
painter.setPen(pen)
painter.setBrush(QtCore.Qt.transparent)
# painter.drawRect(rect)
painter.drawRect(new_rect)
def _cache_pix(self):
rect = self.rect()
rect_width = rect.width()
@ -264,13 +277,7 @@ class ThumbnailPainterWidget(QtWidgets.QWidget):
# Draw drop enabled dashes
if used_default_pix:
pen = QtGui.QPen()
pen.setWidth(1)
pen.setBrush(QtCore.Qt.darkGray)
pen.setStyle(QtCore.Qt.DashLine)
final_painter.setPen(pen)
final_painter.setBrush(QtCore.Qt.transparent)
final_painter.drawRect(rect)
self._paint_dash_line(final_painter, rect)
final_painter.end()

View file

@ -1,715 +0,0 @@
# -*- coding: utf-8 -*-
try:
import commonmark
except Exception:
commonmark = None
from qtpy import QtWidgets, QtCore, QtGui
from openpype.tools.utils import BaseClickableFrame, ClickableFrame
from .widgets import (
IconValuePixmapLabel
)
from ..constants import (
INSTANCE_ID_ROLE
)
class ValidationErrorInstanceList(QtWidgets.QListView):
"""List of publish instances that caused a validation error.
Instances are collected per plugin's validation error title.
"""
def __init__(self, *args, **kwargs):
super(ValidationErrorInstanceList, self).__init__(*args, **kwargs)
self.setObjectName("ValidationErrorInstanceList")
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
def minimumSizeHint(self):
return self.sizeHint()
def sizeHint(self):
result = super(ValidationErrorInstanceList, self).sizeHint()
row_count = self.model().rowCount()
height = 0
if row_count > 0:
height = self.sizeHintForRow(0) * row_count
result.setHeight(height)
return result
class ValidationErrorTitleWidget(QtWidgets.QWidget):
"""Title of validation error.
Widget is used as radio button so requires clickable functionality and
changing style on selection/deselection.
Has toggle button to show/hide instances on which validation error happened
if there is a list (Valdation error may happen on context).
"""
selected = QtCore.Signal(int)
instance_changed = QtCore.Signal(int)
def __init__(self, index, error_info, parent):
super(ValidationErrorTitleWidget, self).__init__(parent)
self._index = index
self._error_info = error_info
self._selected = False
title_frame = ClickableFrame(self)
title_frame.setObjectName("ValidationErrorTitleFrame")
toggle_instance_btn = QtWidgets.QToolButton(title_frame)
toggle_instance_btn.setObjectName("ArrowBtn")
toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
toggle_instance_btn.setMaximumWidth(14)
label_widget = QtWidgets.QLabel(error_info["title"], title_frame)
title_frame_layout = QtWidgets.QHBoxLayout(title_frame)
title_frame_layout.addWidget(label_widget, 1)
title_frame_layout.addWidget(toggle_instance_btn, 0)
instances_model = QtGui.QStandardItemModel()
help_text_by_instance_id = {}
items = []
context_validation = False
for error_item in error_info["error_items"]:
context_validation = error_item.context_validation
if context_validation:
toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow)
description = self._prepare_description(error_item)
help_text_by_instance_id[None] = description
# Add fake item to have minimum size hint of view widget
items.append(QtGui.QStandardItem("Context"))
continue
label = error_item.instance_label
item = QtGui.QStandardItem(label)
item.setFlags(
QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
)
item.setData(label, QtCore.Qt.ToolTipRole)
item.setData(error_item.instance_id, INSTANCE_ID_ROLE)
items.append(item)
description = self._prepare_description(error_item)
help_text_by_instance_id[error_item.instance_id] = description
if items:
root_item = instances_model.invisibleRootItem()
root_item.appendRows(items)
instances_view = ValidationErrorInstanceList(self)
instances_view.setModel(instances_model)
self.setLayoutDirection(QtCore.Qt.LeftToRight)
view_widget = QtWidgets.QWidget(self)
view_layout = QtWidgets.QHBoxLayout(view_widget)
view_layout.setContentsMargins(0, 0, 0, 0)
view_layout.setSpacing(0)
view_layout.addSpacing(14)
view_layout.addWidget(instances_view, 0)
layout = QtWidgets.QVBoxLayout(self)
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(title_frame, 0)
layout.addWidget(view_widget, 0)
view_widget.setVisible(False)
if not context_validation:
toggle_instance_btn.clicked.connect(self._on_toggle_btn_click)
title_frame.clicked.connect(self._mouse_release_callback)
instances_view.selectionModel().selectionChanged.connect(
self._on_seleciton_change
)
self._title_frame = title_frame
self._toggle_instance_btn = toggle_instance_btn
self._view_widget = view_widget
self._instances_model = instances_model
self._instances_view = instances_view
self._context_validation = context_validation
self._help_text_by_instance_id = help_text_by_instance_id
self._expanded = False
def sizeHint(self):
result = super(ValidationErrorTitleWidget, self).sizeHint()
expected_width = max(
self._view_widget.minimumSizeHint().width(),
self._view_widget.sizeHint().width()
)
if expected_width < 200:
expected_width = 200
if result.width() < expected_width:
result.setWidth(expected_width)
return result
def minimumSizeHint(self):
return self.sizeHint()
def _prepare_description(self, error_item):
"""Prepare description text for detail intput.
Args:
error_item (ValidationErrorItem): Item which hold information about
validation error.
Returns:
str: Prepared detailed description.
"""
dsc = error_item.description
detail = error_item.detail
if detail:
dsc += "<br/><br/>{}".format(detail)
description = dsc
if commonmark:
description = commonmark.commonmark(dsc)
return description
def _mouse_release_callback(self):
"""Mark this widget as selected on click."""
self.set_selected(True)
def current_description_text(self):
if self._context_validation:
return self._help_text_by_instance_id[None]
index = self._instances_view.currentIndex()
# TODO make sure instance is selected
if not index.isValid():
index = self._instances_model.index(0, 0)
indence_id = index.data(INSTANCE_ID_ROLE)
return self._help_text_by_instance_id[indence_id]
@property
def is_selected(self):
"""Is widget marked a selected.
Returns:
bool: Item is selected or not.
"""
return self._selected
@property
def index(self):
"""Widget's index set by parent.
Returns:
int: Index of widget.
"""
return self._index
def set_index(self, index):
"""Set index of widget (called by parent).
Args:
int: New index of widget.
"""
self._index = index
def _change_style_property(self, selected):
"""Change style of widget based on selection."""
value = "1" if selected else ""
self._title_frame.setProperty("selected", value)
self._title_frame.style().polish(self._title_frame)
def set_selected(self, selected=None):
"""Change selected state of widget."""
if selected is None:
selected = not self._selected
# Clear instance view selection on deselect
if not selected:
self._instances_view.clearSelection()
# Skip if has same value
if selected == self._selected:
return
self._selected = selected
self._change_style_property(selected)
if selected:
self.selected.emit(self._index)
self._set_expanded(True)
def _on_toggle_btn_click(self):
"""Show/hide instances list."""
self._set_expanded()
def _set_expanded(self, expanded=None):
if expanded is None:
expanded = not self._expanded
elif expanded is self._expanded:
return
if expanded and self._context_validation:
return
self._expanded = expanded
self._view_widget.setVisible(expanded)
if expanded:
self._toggle_instance_btn.setArrowType(QtCore.Qt.DownArrow)
else:
self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
def _on_seleciton_change(self):
sel_model = self._instances_view.selectionModel()
if sel_model.selectedIndexes():
self.instance_changed.emit(self._index)
class ActionButton(BaseClickableFrame):
"""Plugin's action callback button.
Action may have label or icon or both.
Args:
plugin_action_item (PublishPluginActionItem): Action item that can be
triggered by it's id.
"""
action_clicked = QtCore.Signal(str, str)
def __init__(self, plugin_action_item, parent):
super(ActionButton, self).__init__(parent)
self.setObjectName("ValidationActionButton")
self.plugin_action_item = plugin_action_item
action_label = plugin_action_item.label
action_icon = plugin_action_item.icon
label_widget = QtWidgets.QLabel(action_label, self)
icon_label = None
if action_icon:
icon_label = IconValuePixmapLabel(action_icon, self)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(5, 0, 5, 0)
layout.addWidget(label_widget, 1)
if icon_label:
layout.addWidget(icon_label, 0)
self.setSizePolicy(
QtWidgets.QSizePolicy.Minimum,
self.sizePolicy().verticalPolicy()
)
def _mouse_release_callback(self):
self.action_clicked.emit(
self.plugin_action_item.plugin_id,
self.plugin_action_item.action_id
)
class ValidateActionsWidget(QtWidgets.QFrame):
"""Wrapper widget for plugin actions.
Change actions based on selected validation error.
"""
def __init__(self, controller, parent):
super(ValidateActionsWidget, self).__init__(parent)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
content_widget = QtWidgets.QWidget(self)
content_layout = QtWidgets.QVBoxLayout(content_widget)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(content_widget)
self._controller = controller
self._content_widget = content_widget
self._content_layout = content_layout
self._actions_mapping = {}
def clear(self):
"""Remove actions from widget."""
while self._content_layout.count():
item = self._content_layout.takeAt(0)
widget = item.widget()
if widget:
widget.setVisible(False)
widget.deleteLater()
self._actions_mapping = {}
def set_error_item(self, error_item):
"""Set selected plugin and show it's actions.
Clears current actions from widget and recreate them from the plugin.
Args:
Dict[str, Any]: Object holding error items, title and possible
actions to run.
"""
self.clear()
if not error_item:
self.setVisible(False)
return
plugin_action_items = error_item["plugin_action_items"]
for plugin_action_item in plugin_action_items:
if not plugin_action_item.active:
continue
if plugin_action_item.on_filter not in ("failed", "all"):
continue
action_id = plugin_action_item.action_id
self._actions_mapping[action_id] = plugin_action_item
action_btn = ActionButton(plugin_action_item, self._content_widget)
action_btn.action_clicked.connect(self._on_action_click)
self._content_layout.addWidget(action_btn)
if self._content_layout.count() > 0:
self.setVisible(True)
self._content_layout.addStretch(1)
else:
self.setVisible(False)
def _on_action_click(self, plugin_id, action_id):
self._controller.run_action(plugin_id, action_id)
class VerticallScrollArea(QtWidgets.QScrollArea):
"""Scroll area for validation error titles.
The biggest difference is that the scroll area has scroll bar on left side
and resize of content will also resize scrollarea itself.
Resize if deferred by 100ms because at the moment of resize are not yet
propagated sizes and visibility of scroll bars.
"""
def __init__(self, *args, **kwargs):
super(VerticallScrollArea, self).__init__(*args, **kwargs)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
self.setLayoutDirection(QtCore.Qt.RightToLeft)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
# Background of scrollbar will be transparent
scrollbar_bg = self.verticalScrollBar().parent()
if scrollbar_bg:
scrollbar_bg.setAttribute(QtCore.Qt.WA_TranslucentBackground)
self.setViewportMargins(0, 0, 0, 0)
self.verticalScrollBar().installEventFilter(self)
# Timer with 100ms offset after changing size
size_changed_timer = QtCore.QTimer()
size_changed_timer.setInterval(100)
size_changed_timer.setSingleShot(True)
size_changed_timer.timeout.connect(self._on_timer_timeout)
self._size_changed_timer = size_changed_timer
def setVerticalScrollBar(self, widget):
old_widget = self.verticalScrollBar()
if old_widget:
old_widget.removeEventFilter(self)
super(VerticallScrollArea, self).setVerticalScrollBar(widget)
if widget:
widget.installEventFilter(self)
def setWidget(self, widget):
old_widget = self.widget()
if old_widget:
old_widget.removeEventFilter(self)
super(VerticallScrollArea, self).setWidget(widget)
if widget:
widget.installEventFilter(self)
def _on_timer_timeout(self):
width = self.widget().width()
if self.verticalScrollBar().isVisible():
width += self.verticalScrollBar().width()
self.setMinimumWidth(width)
def eventFilter(self, obj, event):
if (
event.type() == QtCore.QEvent.Resize
and (obj is self.widget() or obj is self.verticalScrollBar())
):
self._size_changed_timer.start()
return super(VerticallScrollArea, self).eventFilter(obj, event)
class ValidationArtistMessage(QtWidgets.QWidget):
def __init__(self, message, parent):
super(ValidationArtistMessage, self).__init__(parent)
artist_msg_label = QtWidgets.QLabel(message, self)
artist_msg_label.setAlignment(QtCore.Qt.AlignCenter)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(
artist_msg_label, 1, QtCore.Qt.AlignCenter
)
class ValidationsWidget(QtWidgets.QFrame):
"""Widgets showing validation error.
This widget is shown if validation error/s happened during validation part.
Shows validation error titles with instances on which happened and
validation error detail with possible actions (repair).
titles actions
Error detail
"""
def __init__(self, controller, parent):
super(ValidationsWidget, self).__init__(parent)
# Before publishing
before_publish_widget = ValidationArtistMessage(
"Nothing to report until you run publish", self
)
# After success publishing
publish_started_widget = ValidationArtistMessage(
"So far so good", self
)
# After success publishing
publish_stop_ok_widget = ValidationArtistMessage(
"Publishing finished successfully", self
)
# After failed publishing (not with validation error)
publish_stop_fail_widget = ValidationArtistMessage(
"This is not your fault...", self
)
# Validation errors
validations_widget = QtWidgets.QWidget(self)
content_widget = QtWidgets.QWidget(validations_widget)
errors_scroll = VerticallScrollArea(content_widget)
errors_scroll.setWidgetResizable(True)
errors_widget = QtWidgets.QWidget(errors_scroll)
errors_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
errors_layout = QtWidgets.QVBoxLayout(errors_widget)
errors_layout.setContentsMargins(0, 0, 0, 0)
errors_scroll.setWidget(errors_widget)
error_details_frame = QtWidgets.QFrame(content_widget)
error_details_input = QtWidgets.QTextEdit(error_details_frame)
error_details_input.setObjectName("InfoText")
error_details_input.setTextInteractionFlags(
QtCore.Qt.TextBrowserInteraction
)
actions_widget = ValidateActionsWidget(controller, content_widget)
actions_widget.setMinimumWidth(140)
error_details_layout = QtWidgets.QHBoxLayout(error_details_frame)
error_details_layout.addWidget(error_details_input, 1)
error_details_layout.addWidget(actions_widget, 0)
content_layout = QtWidgets.QHBoxLayout(content_widget)
content_layout.setSpacing(0)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.addWidget(errors_scroll, 0)
content_layout.addWidget(error_details_frame, 1)
top_label = QtWidgets.QLabel(
"Publish validation report", content_widget
)
top_label.setObjectName("PublishInfoMainLabel")
top_label.setAlignment(QtCore.Qt.AlignCenter)
validation_layout = QtWidgets.QVBoxLayout(validations_widget)
validation_layout.setContentsMargins(0, 0, 0, 0)
validation_layout.addWidget(top_label, 0)
validation_layout.addWidget(content_widget, 1)
main_layout = QtWidgets.QStackedLayout(self)
main_layout.addWidget(before_publish_widget)
main_layout.addWidget(publish_started_widget)
main_layout.addWidget(publish_stop_ok_widget)
main_layout.addWidget(publish_stop_fail_widget)
main_layout.addWidget(validations_widget)
main_layout.setCurrentWidget(before_publish_widget)
controller.event_system.add_callback(
"publish.process.started", self._on_publish_start
)
controller.event_system.add_callback(
"publish.reset.finished", self._on_publish_reset
)
controller.event_system.add_callback(
"publish.process.stopped", self._on_publish_stop
)
self._main_layout = main_layout
self._before_publish_widget = before_publish_widget
self._publish_started_widget = publish_started_widget
self._publish_stop_ok_widget = publish_stop_ok_widget
self._publish_stop_fail_widget = publish_stop_fail_widget
self._validations_widget = validations_widget
self._top_label = top_label
self._errors_widget = errors_widget
self._errors_layout = errors_layout
self._error_details_frame = error_details_frame
self._error_details_input = error_details_input
self._actions_widget = actions_widget
self._title_widgets = {}
self._error_info = {}
self._previous_select = None
self._controller = controller
def clear(self):
"""Delete all dynamic widgets and hide all wrappers."""
self._title_widgets = {}
self._error_info = {}
self._previous_select = None
while self._errors_layout.count():
item = self._errors_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
self._top_label.setVisible(False)
self._error_details_frame.setVisible(False)
self._errors_widget.setVisible(False)
self._actions_widget.setVisible(False)
def _set_errors(self, validation_error_report):
"""Set errors into context and created titles.
Args:
validation_error_report (PublishValidationErrorsReport): Report
with information about validation errors and publish plugin
actions.
"""
self.clear()
if not validation_error_report:
return
self._top_label.setVisible(True)
self._error_details_frame.setVisible(True)
self._errors_widget.setVisible(True)
grouped_error_items = validation_error_report.group_items_by_title()
for idx, error_info in enumerate(grouped_error_items):
widget = ValidationErrorTitleWidget(idx, error_info, self)
widget.selected.connect(self._on_select)
widget.instance_changed.connect(self._on_instance_change)
self._errors_layout.addWidget(widget)
self._title_widgets[idx] = widget
self._error_info[idx] = error_info
self._errors_layout.addStretch(1)
if self._title_widgets:
self._title_widgets[0].set_selected(True)
self.updateGeometry()
def _set_current_widget(self, widget):
self._main_layout.setCurrentWidget(widget)
def _on_publish_start(self):
self._set_current_widget(self._publish_started_widget)
def _on_publish_reset(self):
self._set_current_widget(self._before_publish_widget)
def _on_publish_stop(self):
if self._controller.publish_has_crashed:
self._set_current_widget(self._publish_stop_fail_widget)
return
if self._controller.publish_has_validation_errors:
validation_errors = self._controller.get_validation_errors()
self._set_current_widget(self._validations_widget)
self._set_errors(validation_errors)
return
if self._controller.publish_has_finished:
self._set_current_widget(self._publish_stop_ok_widget)
return
self._set_current_widget(self._publish_started_widget)
def _on_select(self, index):
if self._previous_select:
if self._previous_select.index == index:
return
self._previous_select.set_selected(False)
self._previous_select = self._title_widgets[index]
error_item = self._error_info[index]
self._actions_widget.set_error_item(error_item)
self._update_description()
def _on_instance_change(self, index):
if self._previous_select and self._previous_select.index != index:
self._title_widgets[index].set_selected(True)
else:
self._update_description()
def _update_description(self):
description = self._previous_select.current_description_text()
if commonmark:
html = commonmark.commonmark(description)
self._error_details_input.setHtml(html)
elif hasattr(self._error_details_input, "setMarkdown"):
self._error_details_input.setMarkdown(description)
else:
self._error_details_input.setText(description)

View file

@ -40,6 +40,41 @@ from ..constants import (
INPUTS_LAYOUT_VSPACING,
)
FA_PREFIXES = ["", "fa.", "fa5.", "fa5b.", "fa5s.", "ei.", "mdi."]
def parse_icon_def(
icon_def, default_width=None, default_height=None, color=None
):
if not icon_def:
return None
if isinstance(icon_def, QtGui.QPixmap):
return icon_def
color = color or "white"
default_width = default_width or 512
default_height = default_height or 512
if isinstance(icon_def, QtGui.QIcon):
return icon_def.pixmap(default_width, default_height)
try:
if os.path.exists(icon_def):
return QtGui.QPixmap(icon_def)
except Exception:
# TODO logging
pass
for prefix in FA_PREFIXES:
try:
icon_name = "{}{}".format(prefix, icon_def)
icon = qtawesome.icon(icon_name, color=color)
return icon.pixmap(default_width, default_height)
except Exception:
# TODO logging
continue
class PublishPixmapLabel(PixmapLabel):
def _get_pix_size(self):
@ -54,7 +89,6 @@ class IconValuePixmapLabel(PublishPixmapLabel):
Handle icon parsing from creators/instances. Using of QAwesome module
of path to images.
"""
fa_prefixes = ["", "fa."]
default_size = 200
def __init__(self, icon_def, parent):
@ -77,31 +111,9 @@ class IconValuePixmapLabel(PublishPixmapLabel):
return pix
def _parse_icon_def(self, icon_def):
if not icon_def:
return self._default_pixmap()
if isinstance(icon_def, QtGui.QPixmap):
return icon_def
if isinstance(icon_def, QtGui.QIcon):
return icon_def.pixmap(self.default_size, self.default_size)
try:
if os.path.exists(icon_def):
return QtGui.QPixmap(icon_def)
except Exception:
# TODO logging
pass
for prefix in self.fa_prefixes:
try:
icon_name = "{}{}".format(prefix, icon_def)
icon = qtawesome.icon(icon_name, color="white")
return icon.pixmap(self.default_size, self.default_size)
except Exception:
# TODO logging
continue
icon = parse_icon_def(icon_def, self.default_size, self.default_size)
if icon:
return icon
return self._default_pixmap()
@ -692,6 +704,7 @@ class TasksCombobox(QtWidgets.QComboBox):
style.drawControl(
QtWidgets.QStyle.CE_ComboBoxLabel, opt, painter, self
)
painter.end()
def is_valid(self):
"""Are all selected items valid."""

View file

@ -1,3 +1,6 @@
import os
import json
import time
import collections
import copy
from qtpy import QtWidgets, QtCore, QtGui
@ -15,10 +18,11 @@ from openpype.tools.utils import (
from .constants import ResetKeySequence
from .publish_report_viewer import PublishReportViewerWidget
from .control import CardMessageTypes
from .control_qt import QtPublisherController
from .widgets import (
OverviewWidget,
ValidationsWidget,
ReportPageWidget,
PublishFrame,
PublisherTabsWidget,
@ -182,7 +186,7 @@ class PublisherWindow(QtWidgets.QDialog):
controller, content_stacked_widget
)
report_widget = ValidationsWidget(controller, parent)
report_widget = ReportPageWidget(controller, parent)
# Details - Publish details
publish_details_widget = PublishReportViewerWidget(
@ -313,6 +317,13 @@ class PublisherWindow(QtWidgets.QDialog):
controller.event_system.add_callback(
"convertors.find.failed", self._on_convertor_error
)
controller.event_system.add_callback(
"export_report.request", self._export_report
)
controller.event_system.add_callback(
"copy_report.request", self._copy_report
)
# Store extra header widget for TrayPublisher
# - can be used to add additional widgets to header between context
@ -825,6 +836,9 @@ class PublisherWindow(QtWidgets.QDialog):
self._validate_btn.setEnabled(validate_enabled)
self._publish_btn.setEnabled(publish_enabled)
if not publish_enabled:
self._publish_frame.set_shrunk_state(True)
self._update_publish_details_widget()
def _validate_create_instances(self):
@ -941,6 +955,46 @@ class PublisherWindow(QtWidgets.QDialog):
under_mouse = widget_x < global_pos.x()
self._create_overlay_button.set_under_mouse(under_mouse)
def _copy_report(self):
logs = self._controller.get_publish_report()
logs_string = json.dumps(logs, indent=4)
mime_data = QtCore.QMimeData()
mime_data.setText(logs_string)
QtWidgets.QApplication.instance().clipboard().setMimeData(
mime_data
)
self._controller.emit_card_message(
"Report added to clipboard",
CardMessageTypes.info)
def _export_report(self):
default_filename = "publish-report-{}".format(
time.strftime("%y%m%d-%H-%M")
)
default_filepath = os.path.join(
os.path.expanduser("~"),
default_filename
)
new_filepath, ext = QtWidgets.QFileDialog.getSaveFileName(
self, "Save report", default_filepath, ".json"
)
if not ext or not new_filepath:
return
logs = self._controller.get_publish_report()
full_path = new_filepath + ext
dir_path = os.path.dirname(full_path)
if not os.path.exists(dir_path):
os.makedirs(dir_path)
with open(full_path, "w") as file_stream:
json.dump(logs, file_stream)
self._controller.emit_card_message(
"Report saved",
CardMessageTypes.info)
class ErrorsMessageBox(ErrorMessageBox):
def __init__(self, error_title, failed_info, message_start, parent):

View file

@ -1,13 +1,16 @@
from .layouts import FlowLayout
from .widgets import (
FocusSpinBox,
FocusDoubleSpinBox,
ComboBox,
CustomTextComboBox,
PlaceholderLineEdit,
ExpandingTextEdit,
BaseClickableFrame,
ClickableFrame,
ClickableLabel,
ExpandBtn,
ClassicExpandBtn,
PixmapLabel,
IconButton,
PixmapButton,
@ -37,15 +40,19 @@ from .overlay_messages import (
__all__ = (
"FlowLayout",
"FocusSpinBox",
"FocusDoubleSpinBox",
"ComboBox",
"CustomTextComboBox",
"PlaceholderLineEdit",
"ExpandingTextEdit",
"BaseClickableFrame",
"ClickableFrame",
"ClickableLabel",
"ExpandBtn",
"ClassicExpandBtn",
"PixmapLabel",
"IconButton",
"PixmapButton",

View file

@ -0,0 +1,150 @@
from qtpy import QtWidgets, QtCore
class FlowLayout(QtWidgets.QLayout):
"""Layout that organize widgets by minimum size into a flow layout.
Layout is putting widget from left to right and top to bottom. When widget
can't fit a row it is added to next line. Minimum size matches widget with
biggest 'sizeHint' width and height using calculated geometry.
Content margins are part of calculations. It is possible to define
horizontal and vertical spacing.
Layout does not support stretch and spacing items.
Todos:
Unified width concept -> use width of largest item so all of them are
same. This could allow to have minimum columns option too.
"""
def __init__(self, parent=None):
super(FlowLayout, self).__init__(parent)
# spaces between each item
self._horizontal_spacing = 5
self._vertical_spacing = 5
self._items = []
def __del__(self):
while self.count():
self.takeAt(0, False)
def isEmpty(self):
for item in self._items:
if not item.isEmpty():
return False
return True
def setSpacing(self, spacing):
self._horizontal_spacing = spacing
self._vertical_spacing = spacing
self.invalidate()
def setHorizontalSpacing(self, spacing):
self._horizontal_spacing = spacing
self.invalidate()
def setVerticalSpacing(self, spacing):
self._vertical_spacing = spacing
self.invalidate()
def addItem(self, item):
self._items.append(item)
self.invalidate()
def count(self):
return len(self._items)
def itemAt(self, index):
if 0 <= index < len(self._items):
return self._items[index]
return None
def takeAt(self, index, invalidate=True):
if 0 <= index < len(self._items):
item = self._items.pop(index)
if invalidate:
self.invalidate()
return item
return None
def expandingDirections(self):
return QtCore.Qt.Orientations(QtCore.Qt.Vertical)
def hasHeightForWidth(self):
return True
def heightForWidth(self, width):
return self._setup_geometry(QtCore.QRect(0, 0, width, 0), True)
def setGeometry(self, rect):
super(FlowLayout, self).setGeometry(rect)
self._setup_geometry(rect)
def sizeHint(self):
return self.minimumSize()
def minimumSize(self):
size = QtCore.QSize(0, 0)
for item in self._items:
widget = item.widget()
if widget is not None:
parent = widget.parent()
if not widget.isVisibleTo(parent):
continue
size = size.expandedTo(item.minimumSize())
if size.width() < 1 or size.height() < 1:
return size
l_margin, t_margin, r_margin, b_margin = self.getContentsMargins()
size += QtCore.QSize(l_margin + r_margin, t_margin + b_margin)
return size
def _setup_geometry(self, rect, only_calculate=False):
h_spacing = self._horizontal_spacing
v_spacing = self._vertical_spacing
l_margin, t_margin, r_margin, b_margin = self.getContentsMargins()
left_x = rect.x() + l_margin
top_y = rect.y() + t_margin
pos_x = left_x
pos_y = top_y
row_height = 0
for item in self._items:
item_hint = item.sizeHint()
item_width = item_hint.width()
item_height = item_hint.height()
if item_width < 1 or item_height < 1:
continue
end_x = pos_x + item_width
wrap = (
row_height > 0
and (
end_x > rect.right()
or (end_x + r_margin) > rect.right()
)
)
if not wrap:
next_pos_x = end_x + h_spacing
else:
pos_x = left_x
pos_y += row_height + v_spacing
next_pos_x = pos_x + item_width + h_spacing
row_height = 0
if not only_calculate:
item.setGeometry(
QtCore.QRect(pos_x, pos_y, item_width, item_height)
)
pos_x = next_pos_x
row_height = max(row_height, item_height)
height = (pos_y - top_y) + row_height
if height > 0:
height += b_margin
return height

View file

@ -101,6 +101,46 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit):
self.setPalette(filter_palette)
class ExpandingTextEdit(QtWidgets.QTextEdit):
"""QTextEdit which does not have sroll area but expands height."""
def __init__(self, parent=None):
super(ExpandingTextEdit, self).__init__(parent)
size_policy = self.sizePolicy()
size_policy.setHeightForWidth(True)
size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Preferred)
self.setSizePolicy(size_policy)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
doc = self.document()
doc.contentsChanged.connect(self._on_doc_change)
def _on_doc_change(self):
self.updateGeometry()
def hasHeightForWidth(self):
return True
def heightForWidth(self, width):
margins = self.contentsMargins()
document_width = 0
if width >= margins.left() + margins.right():
document_width = width - margins.left() - margins.right()
document = self.document().clone()
document.setTextWidth(document_width)
return margins.top() + document.size().height() + margins.bottom()
def sizeHint(self):
width = super(ExpandingTextEdit, self).sizeHint().width()
return QtCore.QSize(width, self.heightForWidth(width))
class BaseClickableFrame(QtWidgets.QFrame):
"""Widget that catch left mouse click and can trigger a callback.
@ -161,19 +201,34 @@ class ClickableLabel(QtWidgets.QLabel):
class ExpandBtnLabel(QtWidgets.QLabel):
"""Label showing expand icon meant for ExpandBtn."""
state_changed = QtCore.Signal()
def __init__(self, parent):
super(ExpandBtnLabel, self).__init__(parent)
self._source_collapsed_pix = QtGui.QPixmap(
get_style_image_path("branch_closed")
)
self._source_expanded_pix = QtGui.QPixmap(
get_style_image_path("branch_open")
)
self._source_collapsed_pix = self._create_collapsed_pixmap()
self._source_expanded_pix = self._create_expanded_pixmap()
self._current_image = self._source_collapsed_pix
self._collapsed = True
def set_collapsed(self, collapsed):
def _create_collapsed_pixmap(self):
return QtGui.QPixmap(
get_style_image_path("branch_closed")
)
def _create_expanded_pixmap(self):
return QtGui.QPixmap(
get_style_image_path("branch_open")
)
@property
def collapsed(self):
return self._collapsed
def set_collapsed(self, collapsed=None):
if collapsed is None:
collapsed = not self._collapsed
if self._collapsed == collapsed:
return
self._collapsed = collapsed
@ -182,6 +237,7 @@ class ExpandBtnLabel(QtWidgets.QLabel):
else:
self._current_image = self._source_expanded_pix
self._set_resized_pix()
self.state_changed.emit()
def resizeEvent(self, event):
self._set_resized_pix()
@ -203,21 +259,55 @@ class ExpandBtnLabel(QtWidgets.QLabel):
class ExpandBtn(ClickableFrame):
state_changed = QtCore.Signal()
def __init__(self, parent=None):
super(ExpandBtn, self).__init__(parent)
pixmap_label = ExpandBtnLabel(self)
pixmap_label = self._create_pix_widget(self)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(pixmap_label)
pixmap_label.state_changed.connect(self.state_changed)
self._pixmap_label = pixmap_label
def set_collapsed(self, collapsed):
def _create_pix_widget(self, parent=None):
if parent is None:
parent = self
return ExpandBtnLabel(parent)
@property
def collapsed(self):
return self._pixmap_label.collapsed
def set_collapsed(self, collapsed=None):
self._pixmap_label.set_collapsed(collapsed)
class ClassicExpandBtnLabel(ExpandBtnLabel):
def _create_collapsed_pixmap(self):
return QtGui.QPixmap(
get_style_image_path("right_arrow")
)
def _create_expanded_pixmap(self):
return QtGui.QPixmap(
get_style_image_path("down_arrow")
)
class ClassicExpandBtn(ExpandBtn):
"""Same as 'ExpandBtn' but with arrow images."""
def _create_pix_widget(self, parent=None):
if parent is None:
parent = self
return ClassicExpandBtnLabel(parent)
class ImageButton(QtWidgets.QPushButton):
"""PushButton with icon and size of font.

View file

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

View file

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

View file

@ -22,6 +22,9 @@ For [AWS Thinkbox Deadline](https://www.awsthinkbox.com/deadline) support you ne
5. Install our custom plugin and scripts to your deadline repository. It should be as simple as copying content of `openpype/modules/deadline/repository/custom` to `path/to/your/deadline/repository/custom`.
Multiple different DL webservice could be configured. First set them in point 4., then they could be configured per project in `project_settings/deadline/deadline_servers`.
Only single webservice could be a target of publish though.
## Configuration