[Automated] Merged develop into main

This commit is contained in:
pypebot 2022-03-09 04:28:58 +01:00 committed by GitHub
commit 404ef75464
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
76 changed files with 1350 additions and 1747 deletions

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Subset context</title>
<description>
## Invalid subset context
Context of the given subset doesn't match your current scene.
### How to repair?
You can fix this with "repair" button on the right.
</description>
<detail>
### __Detailed Info__ (optional)
This might happen if you are reuse old workfile and open it in different context.
(Eg. you created subset "renderCompositingDefault" from asset "Robot' in "your_project_Robot_compositing.aep", now you opened this workfile in a context "Sloth" but existing subset for "Robot" asset stayed in the workfile.)
</detail>
</error>
</root>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Scene setting</title>
<description>
## Invalid scene setting found
One of the settings in a scene doesn't match to asset settings in database.
{invalid_setting_str}
### How to repair?
Change values for {invalid_keys_str} in the scene OR change them in the asset database if they are wrong there.
</description>
<detail>
### __Detailed Info__ (optional)
This error is shown when for example resolution in the scene doesn't match to resolution set on the asset in the database.
Either value in the database or in the scene is wrong.
</detail>
</error>
<error id="file_not_found">
<title>Scene file doesn't exist</title>
<description>
## Scene file doesn't exist
Collected scene {scene_url} doesn't exist.
### How to repair?
Re-save file, start publish from the beginning again.
</description>
</error>
</root>

View file

@ -1,6 +1,7 @@
from avalon import api
import pyblish.api
import openpype.api
from openpype.pipeline import PublishXmlValidationError
from openpype.hosts.aftereffects.api import get_stub
@ -53,9 +54,8 @@ class ValidateInstanceAsset(pyblish.api.InstancePlugin):
current_asset = api.Session["AVALON_ASSET"]
msg = (
f"Instance asset {instance_asset} is not the same "
f"as current context {current_asset}. PLEASE DO:\n"
f"Repair with 'A' action to use '{current_asset}'.\n"
f"If that's not correct value, close workfile and "
f"reopen via Workfiles!"
f"as current context {current_asset}."
)
assert instance_asset == current_asset, msg
if instance_asset != current_asset:
raise PublishXmlValidationError(self, msg)

View file

@ -5,6 +5,7 @@ import re
import pyblish.api
from openpype.pipeline import PublishXmlValidationError
from openpype.hosts.aftereffects.api import get_asset_settings
@ -99,12 +100,14 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin):
self.log.info("current_settings:: {}".format(current_settings))
invalid_settings = []
invalid_keys = set()
for key, value in expected_settings.items():
if value != current_settings[key]:
invalid_settings.append(
"{} expected: {} found: {}".format(key, value,
current_settings[key])
)
invalid_keys.add(key)
if ((expected_settings.get("handleStart")
or expected_settings.get("handleEnd"))
@ -116,7 +119,27 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin):
msg = "Found invalid settings:\n{}".format(
"\n".join(invalid_settings)
)
assert not invalid_settings, msg
assert os.path.exists(instance.data.get("source")), (
"Scene file not found (saved under wrong name)"
)
if invalid_settings:
invalid_keys_str = ",".join(invalid_keys)
break_str = "<br/>"
invalid_setting_str = "<b>Found invalid settings:</b><br/>{}".\
format(break_str.join(invalid_settings))
formatting_data = {
"invalid_setting_str": invalid_setting_str,
"invalid_keys_str": invalid_keys_str
}
raise PublishXmlValidationError(self, msg,
formatting_data=formatting_data)
if not os.path.exists(instance.data.get("source")):
scene_url = instance.data.get("source")
msg = "Scene file {} not found (saved under wrong name)".format(
scene_url
)
formatting_data = {
"scene_url": scene_url
}
raise PublishXmlValidationError(self, msg, key="file_not_found",
formatting_data=formatting_data)

View file

@ -50,6 +50,10 @@ class ExtractCamera(api.Extractor):
filepath=filepath,
use_active_collection=False,
use_selection=True,
bake_anim_use_nla_strips=False,
bake_anim_use_all_actions=False,
add_leaf_bones=False,
armature_nodetype='ROOT',
object_types={'CAMERA'},
bake_anim_simplify_factor=0.0
)

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Missing audio file</title>
<description>
## Cannot locate linked audio file
Audio file at {audio_url} cannot be found.
### How to repair?
Copy audio file to the highlighted location or remove audio link in the workfile.
</description>
</error>
</root>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Subset context</title>
<description>
## Invalid subset context
Asset name found '{found}' in subsets, expected '{expected}'.
### How to repair?
You can fix this with `Repair` button on the right. This will use '{expected}' asset name and overwrite '{found}' asset name in scene metadata.
After that restart `Publish` with a `Reload button`.
If this is unwanted, close workfile and open again, that way different asset value would be used for context information.
</description>
<detail>
### __Detailed Info__ (optional)
This might happen if you are reuse old workfile and open it in different context.
(Eg. you created subset "renderCompositingDefault" from asset "Robot' in "your_project_Robot_compositing.aep", now you opened this workfile in a context "Sloth" but existing subset for "Robot" asset stayed in the workfile.)
</detail>
</error>
</root>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Scene setting</title>
<description>
## Invalid scene setting found
One of the settings in a scene doesn't match to asset settings in database.
{invalid_setting_str}
### How to repair?
Change values for {invalid_keys_str} in the scene OR change them in the asset database if they are wrong there.
</description>
<detail>
### __Detailed Info__ (optional)
This error is shown when for example resolution in the scene doesn't match to resolution set on the asset in the database.
Either value in the database or in the scene is wrong.
</detail>
</error>
<error id="file_not_found">
<title>Scene file doesn't exist</title>
<description>
## Scene file doesn't exist
Collected scene {scene_url} doesn't exist.
### How to repair?
Re-save file, start publish from the beginning again.
</description>
</error>
</root>

View file

@ -4,6 +4,8 @@ import pyblish.api
import openpype.hosts.harmony.api as harmony
from openpype.pipeline import PublishXmlValidationError
class ValidateAudio(pyblish.api.InstancePlugin):
"""Ensures that there is an audio file in the scene.
@ -42,4 +44,9 @@ class ValidateAudio(pyblish.api.InstancePlugin):
msg = "You are missing audio file:\n{}".format(audio_path)
assert os.path.isfile(audio_path), msg
formatting_data = {
"audio_url": audio_path
}
if os.path.isfile(audio_path):
raise PublishXmlValidationError(self, msg,
formatting_data=formatting_data)

View file

@ -2,6 +2,7 @@ import os
import pyblish.api
import openpype.api
from openpype.pipeline import PublishXmlValidationError
import openpype.hosts.harmony.api as harmony
@ -45,4 +46,11 @@ class ValidateInstance(pyblish.api.InstancePlugin):
"Instance asset is not the same as current asset:"
f"\nInstance: {instance_asset}\nCurrent: {current_asset}"
)
assert instance_asset == current_asset, msg
formatting_data = {
"found": instance_asset,
"expected": current_asset
}
if instance_asset != current_asset:
raise PublishXmlValidationError(self, msg,
formatting_data=formatting_data)

View file

@ -7,7 +7,7 @@ import re
import pyblish.api
import openpype.hosts.harmony.api as harmony
import openpype.hosts.harmony
from openpype.pipeline import PublishXmlValidationError
class ValidateSceneSettingsRepair(pyblish.api.Action):
@ -19,12 +19,12 @@ class ValidateSceneSettingsRepair(pyblish.api.Action):
def process(self, context, plugin):
"""Repair action entry point."""
expected = openpype.hosts.harmony.api.get_asset_settings()
expected = harmony.get_asset_settings()
asset_settings = _update_frames(dict.copy(expected))
asset_settings["frameStart"] = 1
asset_settings["frameEnd"] = asset_settings["frameEnd"] + \
asset_settings["handleEnd"]
openpype.hosts.harmony.api.set_scene_settings(asset_settings)
harmony.set_scene_settings(asset_settings)
if not os.path.exists(context.data["scenePath"]):
self.log.info("correcting scene name")
scene_dir = os.path.dirname(context.data["currentFile"])
@ -55,7 +55,7 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin):
def process(self, instance):
"""Plugin entry point."""
expected_settings = openpype.hosts.harmony.api.get_asset_settings()
expected_settings = harmony.get_asset_settings()
self.log.info("scene settings from DB:".format(expected_settings))
expected_settings = _update_frames(dict.copy(expected_settings))
@ -102,13 +102,13 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin):
self.log.debug("current scene settings {}".format(current_settings))
invalid_settings = []
invalid_keys = set()
for key, value in expected_settings.items():
if value != current_settings[key]:
invalid_settings.append({
"name": key,
"expected": value,
"current": current_settings[key]
})
invalid_settings.append(
"{} expected: {} found: {}".format(key, value,
current_settings[key]))
invalid_keys.add(key)
if ((expected_settings["handleStart"]
or expected_settings["handleEnd"])
@ -120,10 +120,30 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin):
msg = "Found invalid settings:\n{}".format(
json.dumps(invalid_settings, sort_keys=True, indent=4)
)
assert not invalid_settings, msg
assert os.path.exists(instance.context.data.get("scenePath")), (
"Scene file not found (saved under wrong name)"
)
if invalid_settings:
invalid_keys_str = ",".join(invalid_keys)
break_str = "<br/>"
invalid_setting_str = "<b>Found invalid settings:</b><br/>{}".\
format(break_str.join(invalid_settings))
formatting_data = {
"invalid_setting_str": invalid_setting_str,
"invalid_keys_str": invalid_keys_str
}
raise PublishXmlValidationError(self, msg,
formatting_data=formatting_data)
scene_url = instance.context.data.get("scenePath")
if not os.path.exists(scene_url):
msg = "Scene file {} not found (saved under wrong name)".format(
scene_url
)
formatting_data = {
"scene_url": scene_url
}
raise PublishXmlValidationError(self, msg, key="file_not_found",
formatting_data=formatting_data)
def _update_frames(expected_settings):

View file

@ -4,7 +4,6 @@ import os
import sys
import json
import tempfile
import platform
import contextlib
import subprocess
from collections import OrderedDict
@ -64,10 +63,6 @@ def maketx(source, destination, *args):
maketx_path = get_oiio_tools_path("maketx")
if platform.system().lower() == "windows":
# Ensure .exe extension
maketx_path += ".exe"
if not os.path.exists(maketx_path):
print(
"OIIO tool not found in {}".format(maketx_path))

View file

@ -152,6 +152,7 @@ class ExporterReview(object):
"""
data = None
publish_on_farm = False
def __init__(self,
klass,
@ -210,6 +211,9 @@ class ExporterReview(object):
if self.multiple_presets:
repre["outputName"] = self.name
if self.publish_on_farm:
repre["tags"].append("publish_on_farm")
self.data["representations"].append(repre)
def get_view_input_process_node(self):
@ -446,6 +450,7 @@ class ExporterReviewMov(ExporterReview):
return path
def generate_mov(self, farm=False, **kwargs):
self.publish_on_farm = farm
reformat_node_add = kwargs["reformat_node_add"]
reformat_node_config = kwargs["reformat_node_config"]
bake_viewer_process = kwargs["bake_viewer_process"]
@ -563,7 +568,7 @@ class ExporterReviewMov(ExporterReview):
# ---------- end nodes creation
# ---------- render or save to nk
if farm:
if self.publish_on_farm:
nuke.scriptSave()
path_nk = self.save_file()
self.data.update({
@ -573,11 +578,12 @@ class ExporterReviewMov(ExporterReview):
})
else:
self.render(write_node.name())
# ---------- generate representation data
self.get_representation_data(
tags=["review", "delete"] + add_tags,
range=True
)
# ---------- generate representation data
self.get_representation_data(
tags=["review", "delete"] + add_tags,
range=True
)
self.log.debug("Representation... `{}`".format(self.data))

View file

@ -130,9 +130,11 @@ class ExtractReviewDataMov(openpype.api.Extractor):
})
else:
data = exporter.generate_mov(**o_data)
generated_repres.extend(data["representations"])
self.log.info(generated_repres)
# add representation generated by exporter
generated_repres.extend(data["representations"])
self.log.debug(
"__ generated_repres: {}".format(generated_repres))
if generated_repres:
# assign to representations

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Missing source video file</title>
<description>
## No attached video file found
Process expects presence of source video file with same name prefix as an editorial file in same folder.
(example `simple_editorial_setup_Layer1.edl` expects `simple_editorial_setup.mp4` in same folder)
### How to repair?
Copy source video file to the folder next to `.edl` file. (On a disk, do not put it into Standalone Publisher.)
</description>
</error>
</root>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Invalid frame range</title>
<description>
## Invalid frame range
Expected duration or '{duration}' frames set in database, workfile contains only '{found}' frames.
### How to repair?
Modify configuration in the database or tweak frame range in the workfile.
</description>
</error>
</root>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Duplicate shots</title>
<description>
## Duplicate shot names
Process contains duplicated shot names '{duplicates_str}'.
### How to repair?
Remove shot duplicates.
</description>
</error>
</root>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Files not found</title>
<description>
## Source files not found
Process contains duplicated shot names:
'{files_not_found}'
### How to repair?
Add missing files or run Publish again to collect new publishable files.
</description>
</error>
</root>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Task not found</title>
<description>
## Task not found in database
Process contains tasks that don't exist in database:
'{task_not_found}'
### How to repair?
Remove set task or add task into database into proper place.
</description>
</error>
</root>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>No texture files found</title>
<description>
## Batch doesn't contain texture files
Batch must contain at least one texture file.
### How to repair?
Add texture file to the batch or check name if it follows naming convention to match texture files to the batch.
</description>
</error>
</root>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>No workfile found</title>
<description>
## Batch should contain workfile
It is expected that published contains workfile that served as a source for textures.
### How to repair?
Add workfile to the batch, or disable this validator if you do not want workfile published.
</description>
</error>
</root>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Asset name not found</title>
<description>
## Couldn't parse asset name from a file
Unable to parse asset name from '{file_name}'. File name doesn't match configured naming convention.
### How to repair?
Check Settings: project_settings/standalonepublisher/publish/CollectTextures for naming convention.
</description>
<detail>
### __Detailed Info__ (optional)
This error happens when parsing cannot figure out name of asset texture files belong under.
</detail>
</error>
<error id="missing_values">
<title>Missing keys</title>
<description>
## Texture file name is missing some required keys
Texture '{file_name}' is missing values for {missing_str} keys.
### How to repair?
Fix name of texture file and Publish again.
</description>
</error>
</root>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Texture version</title>
<description>
## Texture version mismatch with workfile
Workfile '{file_name}' version doesn't match with '{version}' of a texture.
### How to repair?
Rename either workfile or texture to contain matching versions
</description>
<detail>
### __Detailed Info__ (optional)
This might happen if you are trying to publish textures for older version of workfile (or the other way).
(Eg. publishing 'workfile_v001' and 'texture_file_v002')
</detail>
</error>
<error id="too_many">
<title>Too many versions</title>
<description>
## Too many versions published at same time
It is currently expected to publish only batch with single version.
Found {found} versions.
### How to repair?
Please remove files with different version and split publishing into multiple steps.
</description>
</error>
</root>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>No secondary workfile</title>
<description>
## No secondary workfile found
Current process expects that primary workfile (for example with a extension '{extension}') will contain also 'secondary' workfile.
Secondary workfile for '{file_name}' wasn't found.
### How to repair?
Attach secondary workfile or disable this validator and Publish again.
</description>
<detail>
### __Detailed Info__ (optional)
This process was implemented for a possible use case of first workfile coming from Mari, secondary workfile for textures from Substance.
Publish should contain both if primary workfile is present.
</detail>
</error>
</root>

View file

@ -1,5 +1,6 @@
import pyblish.api
import openpype.api
from openpype.pipeline import PublishXmlValidationError
class ValidateEditorialResources(pyblish.api.InstancePlugin):
@ -19,5 +20,7 @@ class ValidateEditorialResources(pyblish.api.InstancePlugin):
f"Instance: {instance}, Families: "
f"{[instance.data['family']] + instance.data['families']}")
check_file = instance.data["editorialSourcePath"]
msg = f"Missing \"{check_file}\"."
assert check_file, msg
msg = "Missing source video file."
if not check_file:
raise PublishXmlValidationError(self, msg)

View file

@ -1,8 +1,10 @@
import re
import pyblish.api
import openpype.api
from openpype import lib
from openpype.pipeline import PublishXmlValidationError
class ValidateFrameRange(pyblish.api.InstancePlugin):
@ -48,9 +50,15 @@ class ValidateFrameRange(pyblish.api.InstancePlugin):
files = [files]
frames = len(files)
err_msg = "Frame duration from DB:'{}' ". format(int(duration)) +\
" doesn't match number of files:'{}'".format(frames) +\
" Please change frame range for Asset or limit no. of files"
assert frames == duration, err_msg
msg = "Frame duration from DB:'{}' ". format(int(duration)) +\
" doesn't match number of files:'{}'".format(frames) +\
" Please change frame range for Asset or limit no. of files"
self.log.debug("Valid ranges {} - {}".format(int(duration), frames))
formatting_data = {"duration": duration,
"found": frames}
if frames != duration:
raise PublishXmlValidationError(self, msg,
formatting_data=formatting_data)
self.log.debug("Valid ranges expected '{}' - found '{}'".
format(int(duration), frames))

View file

@ -1,6 +1,7 @@
import pyblish.api
import openpype.api
import openpype.api
from openpype.pipeline import PublishXmlValidationError
class ValidateShotDuplicates(pyblish.api.ContextPlugin):
"""Validating no duplicate names are in context."""
@ -20,4 +21,8 @@ class ValidateShotDuplicates(pyblish.api.ContextPlugin):
shot_names.append(name)
msg = "There are duplicate shot names:\n{}".format(duplicate_names)
assert not duplicate_names, msg
formatting_data = {"duplicates_str": ','.join(duplicate_names)}
if duplicate_names:
raise PublishXmlValidationError(self, msg,
formatting_data=formatting_data)

View file

@ -1,8 +1,10 @@
import pyblish.api
import openpype.api
import os
import pyblish.api
import openpype.api
from openpype.pipeline import PublishXmlValidationError
class ValidateSources(pyblish.api.InstancePlugin):
"""Validates source files.
@ -11,7 +13,6 @@ class ValidateSources(pyblish.api.InstancePlugin):
got deleted between starting of SP and now.
"""
order = openpype.api.ValidateContentsOrder
label = "Check source files"
@ -22,6 +23,7 @@ class ValidateSources(pyblish.api.InstancePlugin):
def process(self, instance):
self.log.info("instance {}".format(instance.data))
missing_files = set()
for repre in instance.data.get("representations") or []:
files = []
if isinstance(repre["files"], str):
@ -34,4 +36,10 @@ class ValidateSources(pyblish.api.InstancePlugin):
file_name)
if not os.path.exists(source_file):
raise ValueError("File {} not found".format(source_file))
missing_files.add(source_file)
msg = "Files '{}' not found".format(','.join(missing_files))
formatting_data = {"files_not_found": ' - {}'.join(missing_files)}
if missing_files:
raise PublishXmlValidationError(self, msg,
formatting_data=formatting_data)

View file

@ -1,6 +1,8 @@
import pyblish.api
from avalon import io
from openpype.pipeline import PublishXmlValidationError
class ValidateTaskExistence(pyblish.api.ContextPlugin):
"""Validating tasks on instances are filled and existing."""
@ -53,4 +55,9 @@ class ValidateTaskExistence(pyblish.api.ContextPlugin):
"Asset: \"{}\" Task: \"{}\"".format(*missing_pair)
)
raise AssertionError(msg.format("\n".join(pair_msgs)))
msg = msg.format("\n".join(pair_msgs))
formatting_data = {"task_not_found": ' - {}'.join(pair_msgs)}
if pair_msgs:
raise PublishXmlValidationError(self, msg,
formatting_data=formatting_data)

View file

@ -1,6 +1,8 @@
import pyblish.api
import openpype.api
from openpype.pipeline import PublishXmlValidationError
class ValidateTextureBatch(pyblish.api.InstancePlugin):
"""Validates that some texture files are present."""
@ -15,8 +17,10 @@ class ValidateTextureBatch(pyblish.api.InstancePlugin):
present = False
for instance in instance.context:
if instance.data["family"] == "textures":
self.log.info("Some textures present.")
self.log.info("At least some textures present.")
return
assert present, "No textures found in published batch!"
msg = "No textures found in published batch!"
if not present:
raise PublishXmlValidationError(self, msg)

View file

@ -1,5 +1,7 @@
import pyblish.api
import openpype.api
from openpype.pipeline import PublishXmlValidationError
class ValidateTextureHasWorkfile(pyblish.api.InstancePlugin):
@ -17,4 +19,6 @@ class ValidateTextureHasWorkfile(pyblish.api.InstancePlugin):
def process(self, instance):
wfile = instance.data["versionData"].get("workfile")
assert wfile, "Textures are missing attached workfile"
msg = "Textures are missing attached workfile"
if not wfile:
raise PublishXmlValidationError(self, msg)

View file

@ -1,6 +1,7 @@
import pyblish.api
import openpype.api
import openpype.api
from openpype.pipeline import PublishXmlValidationError
class ValidateTextureBatchNaming(pyblish.api.InstancePlugin):
"""Validates that all instances had properly formatted name."""
@ -19,9 +20,13 @@ class ValidateTextureBatchNaming(pyblish.api.InstancePlugin):
msg = "Couldn't find asset name in '{}'\n".format(file_name) + \
"File name doesn't follow configured pattern.\n" + \
"Please rename the file."
assert "NOT_AVAIL" not in instance.data["asset_build"], msg
instance.data.pop("asset_build")
formatting_data = {"file_name": file_name}
if "NOT_AVAIL" in instance.data["asset_build"]:
raise PublishXmlValidationError(self, msg,
formatting_data=formatting_data)
instance.data.pop("asset_build") # not needed anymore
if instance.data["family"] == "textures":
file_name = instance.data["representations"][0]["files"][0]
@ -47,4 +52,10 @@ class ValidateTextureBatchNaming(pyblish.api.InstancePlugin):
"Name of the texture file doesn't match expected pattern.\n" + \
"Please rename file(s) {}".format(file_name)
assert not missing_key_values, msg
missing_str = ','.join(["'{}'".format(key)
for key in missing_key_values])
formatting_data = {"file_name": file_name,
"missing_str": missing_str}
if missing_key_values:
raise PublishXmlValidationError(self, msg, key="missing_values",
formatting_data=formatting_data)

View file

@ -1,5 +1,7 @@
import pyblish.api
import openpype.api
from openpype.pipeline import PublishXmlValidationError
class ValidateTextureBatchVersions(pyblish.api.InstancePlugin):
@ -25,14 +27,21 @@ class ValidateTextureBatchVersions(pyblish.api.InstancePlugin):
self.log.info("No workfile present for textures")
return
msg = "Not matching version: texture v{:03d} - workfile {}"
assert version_str in wfile, \
if version_str not in wfile:
msg = "Not matching version: texture v{:03d} - workfile {}"
msg.format(
instance.data["version"], wfile
)
raise PublishXmlValidationError(self, msg)
present_versions = set()
for instance in instance.context:
present_versions.add(instance.data["version"])
assert len(present_versions) == 1, "Too many versions in a batch!"
if len(present_versions) != 1:
msg = "Too many versions in a batch!"
found = ','.join(["'{}'".format(val) for val in present_versions])
formatting_data = {"found": found}
raise PublishXmlValidationError(self, msg, key="too_many",
formatting_data=formatting_data)

View file

@ -1,11 +1,13 @@
import pyblish.api
import openpype.api
from openpype.pipeline import PublishXmlValidationError
class ValidateTextureBatchWorkfiles(pyblish.api.InstancePlugin):
"""Validates that textures workfile has collected resources (optional).
Collected recourses means secondary workfiles (in most cases).
Collected resources means secondary workfiles (in most cases).
"""
label = "Validate Texture Workfile Has Resources"
@ -24,6 +26,13 @@ class ValidateTextureBatchWorkfiles(pyblish.api.InstancePlugin):
self.log.warning("Only secondary workfile present!")
return
msg = "No secondary workfiles present for workfile {}".\
format(instance.data["name"])
assert instance.data.get("resources"), msg
if not instance.data.get("resources"):
msg = "No secondary workfile present for workfile '{}'". \
format(instance.data["name"])
ext = self.main_workfile_extensions[0]
formatting_data = {"file_name": instance.data["name"],
"extension": ext}
raise PublishXmlValidationError(self, msg,
formatting_data=formatting_data
)

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Subset context</title>
<description>## Invalid subset context
Context of the given subset doesn't match your current scene.
### How to repair?
Yout can fix this with "Repair" button on the right. This will use '{expected_asset}' asset name and overwrite '{found_asset}' asset name in scene metadata.
After that restart publishing with Reload button.
</description>
<detail>
### How could this happen?
The subset was created in different scene with different context
or the scene file was copy pasted from different context.
</detail>
</error>
</root>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Layer names</title>
<description>## Duplicated layer names
Can't determine which layers should be published because there are duplicated layer names in the scene.
### Duplicated layer names
{layer_names}
*Check layer names for all subsets in list on left side.*
### How to repair?
Hide/rename/remove layers that should not be published.
If all of them should be published then you have duplicated subset names in the scene. In that case you have to recrete them and use different variant name.
</description>
</error>
</root>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Layers visiblity</title>
<description>## All layers are not visible
Layers visibility was changed during publishing which caused that all layers for subset "{instance_name}" are hidden.
### Layer names for **{instance_name}**
{layer_names}
*Check layer names for all subsets in the list on the left side.*
### How to repair?
Reset publishing and do not change visibility of layers after hitting publish button.
</description>
</error>
</root>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Frame range</title>
<description>## Invalid render frame range
Scene frame range which will be rendered is defined by MarkIn and MarkOut. Expected frame range is {expected_frame_range} and current frame range is {current_frame_range}.
It is also required that MarkIn and MarkOut are enabled in the scene. Their color is highlighted on timeline when are enabled.
- MarkIn is {mark_in_enable_state}
- MarkOut is {mark_out_enable_state}
### How to repair?
Yout can fix this with "Repair" button on the right. That will change MarkOut to {expected_mark_out}.
Or you can manually modify MarkIn and MarkOut in the scene timeline.
</description>
</error>
</root>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Missing layers</title>
<description>## Missing layers for render pass
Render pass subset "{instance_name}" has stored layer names that belong to it's rendering scope but layers were not found in scene.
### Missing layer names
{layer_names}
### How to repair?
Find layers that belong to subset {instance_name} and rename them back to expected layer names or remove the subset and create new with right layers.
</description>
</error>
</root>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Render pass group</title>
<description>## Invalid group of Render Pass layers
Layers of Render Pass {instance_name} belong to Render Group which is defined by TVPaint color group {expected_group}. But the layers are not in the group.
### How to repair?
Change the color group to {expected_group} on layers {layer_names}.
</description>
</error>
</root>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Scene settings</title>
<description>## Invalid scene settings
Scene settings do not match to expected values.
**FPS**
- Expected value: {expected_fps}
- Current value: {current_fps}
**Resolution**
- Expected value: {expected_width}x{expected_height}
- Current value: {current_width}x{current_height}
**Pixel ratio**
- Expected value: {expected_pixel_ratio}
- Current value: {current_pixel_ratio}
### How to repair?
FPS and Pixel ratio can be modified in scene setting. Wrong resolution can be fixed with changing resolution of scene but due to TVPaint limitations it is possible that you will need to create new scene.
</description>
</error>
</root>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>First frame</title>
<description>## MarkIn is not set to 0
MarkIn in your scene must start from 0 fram index but MarkIn is set to {current_start_frame}.
### How to repair?
You can modify MarkIn manually or hit the "Repair" button on the right which will change MarkIn to 0 (does not change MarkOut).
</description>
</error>
</root>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Missing metadata</title>
<description>## Your scene miss context metadata
Your scene does not contain metadata about {missing_metadata}.
### How to repair?
Resave the scene using Workfiles tool or hit the "Repair" button on the right.
</description>
<detail>
### How this could happend?
You're using scene file that was not created using Workfiles tool.
</detail>
</error>
</root>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Project name</title>
<description>## Your scene is from different project
It is not possible to publish into project "{workfile_project_name}" when TVPaint was opened with project "{env_project_name}" in context.
### How to repair?
If the workfile belongs to project "{env_project_name}" then use Workfiles tool to resave it.
Otherwise close TVPaint and launch it again from project you want to publish in.
</description>
<detail>
### How this could happend?
You've opened workfile from different project. You've opened TVPaint on a task from "{env_project_name}" then you've opened TVPaint again on task from "{workfile_project_name}" without closing the TVPaint. Because TVPaint can run only once the project didn't change.
### Why it is important?
Because project may affect how TVPaint works or change publishing behavior it is dangerous to allow change project context in many ways. For example publishing will not run as expected.
</detail>
</error>
</root>

View file

@ -1,4 +1,5 @@
import pyblish.api
from openpype.pipeline import PublishXmlValidationError
from openpype.hosts.tvpaint.api import pipeline
@ -27,7 +28,7 @@ class FixAssetNames(pyblish.api.Action):
pipeline._write_instances(new_instance_items)
class ValidateMissingLayers(pyblish.api.ContextPlugin):
class ValidateAssetNames(pyblish.api.ContextPlugin):
"""Validate assset name present on instance.
Asset name on instance should be the same as context's.
@ -48,8 +49,18 @@ class ValidateMissingLayers(pyblish.api.ContextPlugin):
instance_label = (
instance.data.get("label") or instance.data["name"]
)
raise AssertionError((
"Different asset name on instance then context's."
" Instance \"{}\" has asset name: \"{}\""
" Context asset name is: \"{}\""
).format(instance_label, asset_name, context_asset_name))
raise PublishXmlValidationError(
self,
(
"Different asset name on instance then context's."
" Instance \"{}\" has asset name: \"{}\""
" Context asset name is: \"{}\""
).format(
instance_label, asset_name, context_asset_name
),
formatting_data={
"expected_asset": context_asset_name,
"found_asset": asset_name
}
)

View file

@ -1,4 +1,5 @@
import pyblish.api
from openpype.pipeline import PublishXmlValidationError
class ValidateLayersGroup(pyblish.api.InstancePlugin):
@ -30,14 +31,20 @@ class ValidateLayersGroup(pyblish.api.InstancePlugin):
"\"{}\"".format(layer_name)
for layer_name in duplicated_layer_names
])
# Raise an error
raise AssertionError(
detail_lines = [
"- {}".format(layer_name)
for layer_name in set(duplicated_layer_names)
]
raise PublishXmlValidationError(
self,
(
"Layers have duplicated names for instance {}."
# Description what's wrong
" There are layers with same name and one of them is marked"
" for publishing so it is not possible to know which should"
" be published. Please look for layers with names: {}"
).format(instance.data["label"], layers_msg)
).format(instance.data["label"], layers_msg),
formatting_data={
"layer_names": "<br/>".join(detail_lines)
}
)

View file

@ -1,6 +1,8 @@
import pyblish.api
from openpype.pipeline import PublishXmlValidationError
# TODO @iLLiCiTiT add repair action to disable instances?
class ValidateLayersVisiblity(pyblish.api.InstancePlugin):
"""Validate existence of renderPass layers."""
@ -9,8 +11,26 @@ class ValidateLayersVisiblity(pyblish.api.InstancePlugin):
families = ["review", "renderPass", "renderLayer"]
def process(self, instance):
layer_names = set()
for layer in instance.data["layers"]:
layer_names.add(layer["name"])
if layer["visible"]:
return
raise AssertionError("All layers of instance are not visible.")
instance_label = (
instance.data.get("label") or instance.data["name"]
)
raise PublishXmlValidationError(
self,
"All layers of instance \"{}\" are not visible.".format(
instance_label
),
formatting_data={
"instance_name": instance_label,
"layer_names": "<br/>".join([
"- {}".format(layer_name)
for layer_name in layer_names
])
}
)

View file

@ -1,6 +1,7 @@
import json
import pyblish.api
from openpype.pipeline import PublishXmlValidationError
from openpype.hosts.tvpaint.api import lib
@ -73,9 +74,34 @@ class ValidateMarks(pyblish.api.ContextPlugin):
"expected": expected_data[k]
}
if invalid:
raise AssertionError(
"Marks does not match database:\n{}".format(
json.dumps(invalid, sort_keys=True, indent=4)
)
)
# Validation ends
if not invalid:
return
current_frame_range = (
(current_data["markOut"] - current_data["markIn"]) + 1
)
expected_frame_range = (
(expected_data["markOut"] - expected_data["markIn"]) + 1
)
mark_in_enable_state = "disabled"
if current_data["markInState"]:
mark_in_enable_state = "enabled"
mark_out_enable_state = "disabled"
if current_data["markOutState"]:
mark_out_enable_state = "enabled"
raise PublishXmlValidationError(
self,
"Marks does not match database:\n{}".format(
json.dumps(invalid, sort_keys=True, indent=4)
),
formatting_data={
"current_frame_range": str(current_frame_range),
"expected_frame_range": str(expected_frame_range),
"mark_in_enable_state": mark_in_enable_state,
"mark_out_enable_state": mark_out_enable_state,
"expected_mark_out": expected_data["markOut"]
}
)

View file

@ -1,4 +1,5 @@
import pyblish.api
from openpype.pipeline import PublishXmlValidationError
class ValidateMissingLayers(pyblish.api.InstancePlugin):
@ -30,13 +31,25 @@ class ValidateMissingLayers(pyblish.api.InstancePlugin):
"\"{}\"".format(layer_name)
for layer_name in missing_layer_names
])
instance_label = (
instance.data.get("label") or instance.data["name"]
)
description_layer_names = "<br/>".join([
"- {}".format(layer_name)
for layer_name in missing_layer_names
])
# Raise an error
raise AssertionError(
raise PublishXmlValidationError(
self,
(
"Layers were not found by name for instance \"{}\"."
# Description what's wrong
" Layer names marked for publishing are not available"
" in layers list. Missing layer names: {}"
).format(instance.data["label"], layers_msg)
).format(instance.data["label"], layers_msg),
formatting_data={
"instance_name": instance_label,
"layer_names": description_layer_names
}
)

View file

@ -1,34 +0,0 @@
import json
import pyblish.api
class ValidateProjectSettings(pyblish.api.ContextPlugin):
"""Validate project settings against database.
"""
label = "Validate Project Settings"
order = pyblish.api.ValidatorOrder
optional = True
def process(self, context):
scene_data = {
"fps": context.data.get("sceneFps"),
"resolutionWidth": context.data.get("sceneWidth"),
"resolutionHeight": context.data.get("sceneHeight"),
"pixelAspect": context.data.get("scenePixelAspect")
}
invalid = {}
for k in scene_data.keys():
expected_value = context.data["assetEntity"]["data"][k]
if scene_data[k] != expected_value:
invalid[k] = {
"current": scene_data[k], "expected": expected_value
}
if invalid:
raise AssertionError(
"Project settings does not match database:\n{}".format(
json.dumps(invalid, sort_keys=True, indent=4)
)
)

View file

@ -1,5 +1,6 @@
import collections
import pyblish.api
from openpype.pipeline import PublishXmlValidationError
class ValidateLayersGroup(pyblish.api.InstancePlugin):
@ -26,11 +27,13 @@ class ValidateLayersGroup(pyblish.api.InstancePlugin):
layer_names = instance.data["layer_names"]
# Check if all layers from render pass are in right group
invalid_layers_by_group_id = collections.defaultdict(list)
invalid_layer_names = set()
for layer_name in layer_names:
layer = layers_by_name.get(layer_name)
_group_id = layer["group_id"]
if _group_id != group_id:
invalid_layers_by_group_id[_group_id].append(layer)
invalid_layer_names.add(layer_name)
# Everything is OK and skip exception
if not invalid_layers_by_group_id:
@ -61,16 +64,27 @@ class ValidateLayersGroup(pyblish.api.InstancePlugin):
)
# Raise an error
raise AssertionError((
# Short message
"Layers in wrong group."
# Description what's wrong
" Layers from render pass \"{}\" must be in group {} (id: {})."
# Detailed message
" Layers in wrong group: {}"
).format(
instance.data["label"],
correct_group["name"],
correct_group["group_id"],
" | ".join(per_group_msgs)
))
raise PublishXmlValidationError(
self,
(
# Short message
"Layers in wrong group."
# Description what's wrong
" Layers from render pass \"{}\" must be in group {} (id: {})."
# Detailed message
" Layers in wrong group: {}"
).format(
instance.data["label"],
correct_group["name"],
correct_group["group_id"],
" | ".join(per_group_msgs)
),
formatting_data={
"instance_name": (
instance.data.get("label") or instance.data["name"]
),
"expected_group": correct_group["name"],
"layer_names": ", ".join(invalid_layer_names)
}
)

View file

@ -0,0 +1,49 @@
import json
import pyblish.api
from openpype.pipeline import PublishXmlValidationError
# TODO @iLliCiTiT add fix action for fps
class ValidateProjectSettings(pyblish.api.ContextPlugin):
"""Validate scene settings against database."""
label = "Validate Scene Settings"
order = pyblish.api.ValidatorOrder
optional = True
def process(self, context):
expected_data = context.data["assetEntity"]["data"]
scene_data = {
"fps": context.data.get("sceneFps"),
"resolutionWidth": context.data.get("sceneWidth"),
"resolutionHeight": context.data.get("sceneHeight"),
"pixelAspect": context.data.get("scenePixelAspect")
}
invalid = {}
for k in scene_data.keys():
expected_value = expected_data[k]
if scene_data[k] != expected_value:
invalid[k] = {
"current": scene_data[k], "expected": expected_value
}
if not invalid:
return
raise PublishXmlValidationError(
self,
"Scene settings does not match database:\n{}".format(
json.dumps(invalid, sort_keys=True, indent=4)
),
formatting_data={
"expected_fps": expected_data["fps"],
"current_fps": scene_data["fps"],
"expected_width": expected_data["resolutionWidth"],
"expected_height": expected_data["resolutionHeight"],
"current_width": scene_data["resolutionWidth"],
"current_height": scene_data["resolutionWidth"],
"expected_pixel_ratio": expected_data["pixelAspect"],
"current_pixel_ratio": scene_data["pixelAspect"]
}
)

View file

@ -1,4 +1,5 @@
import pyblish.api
from openpype.pipeline import PublishXmlValidationError
from openpype.hosts.tvpaint.api import lib
@ -24,4 +25,13 @@ class ValidateStartFrame(pyblish.api.ContextPlugin):
def process(self, context):
start_frame = lib.execute_george("tv_startframe")
assert int(start_frame) == 0, "Start frame has to be frame 0."
if start_frame == 0:
return
raise PublishXmlValidationError(
self,
"Start frame has to be frame 0.",
formatting_data={
"current_start_frame": start_frame
}
)

View file

@ -1,4 +1,5 @@
import pyblish.api
from openpype.pipeline import PublishXmlValidationError
from openpype.hosts.tvpaint.api import save_file
@ -42,8 +43,12 @@ class ValidateWorkfileMetadata(pyblish.api.ContextPlugin):
missing_keys.append(key)
if missing_keys:
raise AssertionError(
raise PublishXmlValidationError(
self,
"Current workfile is missing metadata about {}.".format(
", ".join(missing_keys)
)
),
formatting_data={
"missing_metadata": ", ".join(missing_keys)
}
)

View file

@ -1,5 +1,6 @@
import os
import pyblish.api
from openpype.pipeline import PublishXmlValidationError
class ValidateWorkfileProjectName(pyblish.api.ContextPlugin):
@ -31,15 +32,23 @@ class ValidateWorkfileProjectName(pyblish.api.ContextPlugin):
return
# Raise an error
raise AssertionError((
# Short message
"Workfile from different Project ({})."
# Description what's wrong
" It is not possible to publish when TVPaint was launched in"
"context of different project. Current context project is \"{}\"."
" Launch TVPaint in context of project \"{}\" and then publish."
).format(
workfile_project_name,
env_project_name,
workfile_project_name,
))
raise PublishXmlValidationError(
self,
(
# Short message
"Workfile from different Project ({})."
# Description what's wrong
" It is not possible to publish when TVPaint was launched in"
"context of different project. Current context project is"
" \"{}\". Launch TVPaint in context of project \"{}\""
" and then publish."
).format(
workfile_project_name,
env_project_name,
workfile_project_name,
),
formatting_data={
"workfile_project_name": workfile_project_name,
"expected_project_name": env_project_name
}
)

View file

@ -10,14 +10,18 @@ Provides:
import os
import clique
import tempfile
import math
from avalon import io
import pyblish.api
from openpype.lib import prepare_template_data
from openpype.lib import prepare_template_data, get_asset, ffprobe_streams
from openpype.lib.vendor_bin_utils import get_fps
from openpype.lib.plugin_tools import (
parse_json,
get_subset_name_with_asset_doc
)
class CollectPublishedFiles(pyblish.api.ContextPlugin):
"""
This collector will try to find json files in provided
@ -49,10 +53,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin):
self.log.info("task_sub:: {}".format(task_subfolders))
asset_name = context.data["asset"]
asset_doc = io.find_one({
"type": "asset",
"name": asset_name
})
asset_doc = get_asset()
task_name = context.data["task"]
task_type = context.data["taskType"]
project_name = context.data["project_name"]
@ -97,11 +98,26 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin):
instance.data["frameEnd"] = \
instance.data["representations"][0]["frameEnd"]
else:
instance.data["frameStart"] = 0
instance.data["frameEnd"] = 1
frame_start = asset_doc["data"]["frameStart"]
instance.data["frameStart"] = frame_start
instance.data["frameEnd"] = asset_doc["data"]["frameEnd"]
instance.data["representations"] = self._get_single_repre(
task_dir, task_data["files"], tags
)
file_url = os.path.join(task_dir, task_data["files"][0])
duration = self._get_duration(file_url)
if duration:
try:
frame_end = int(frame_start) + math.ceil(duration)
instance.data["frameEnd"] = math.ceil(frame_end)
self.log.debug("frameEnd:: {}".format(
instance.data["frameEnd"]))
except ValueError:
self.log.warning("Unable to count frames "
"duration {}".format(duration))
instance.data["handleStart"] = asset_doc["data"]["handleStart"]
instance.data["handleEnd"] = asset_doc["data"]["handleEnd"]
self.log.info("instance.data:: {}".format(instance.data))
@ -127,7 +143,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin):
return [repre_data]
def _process_sequence(self, files, task_dir, tags):
"""Prepare reprentations for sequence of files."""
"""Prepare representation for sequence of files."""
collections, remainder = clique.assemble(files)
assert len(collections) == 1, \
"Too many collections in {}".format(files)
@ -188,6 +204,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin):
msg = "No family found for combination of " +\
"task_type: {}, is_sequence:{}, extension: {}".format(
task_type, is_sequence, extension)
found_family = "render"
assert found_family, msg
return (found_family,
@ -243,3 +260,41 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin):
return version[0].get("version") or 0
else:
return 0
def _get_duration(self, file_url):
"""Return duration in frames"""
try:
streams = ffprobe_streams(file_url, self.log)
except Exception as exc:
raise AssertionError((
"FFprobe couldn't read information about input file: \"{}\"."
" Error message: {}"
).format(file_url, str(exc)))
first_video_stream = None
for stream in streams:
if "width" in stream and "height" in stream:
first_video_stream = stream
break
if first_video_stream:
nb_frames = stream.get("nb_frames")
if nb_frames:
try:
return int(nb_frames)
except ValueError:
self.log.warning(
"nb_frames {} not convertible".format(nb_frames))
duration = stream.get("duration")
frame_rate = get_fps(stream.get("r_frame_rate", '0/0'))
self.log.debu("duration:: {} frame_rate:: {}".format(
duration, frame_rate))
try:
return float(duration) * float(frame_rate)
except ValueError:
self.log.warning(
"{} or {} cannot be converted".format(duration,
frame_rate))
self.log.warning("Cannot get number of frames")

View file

@ -16,6 +16,14 @@ sys.path.insert(0, python_version_dir)
site.addsitedir(python_version_dir)
from .vendor_bin_utils import (
find_executable,
get_vendor_bin_path,
get_oiio_tools_path,
get_ffmpeg_tool_path,
ffprobe_streams,
is_oiio_supported
)
from .env_tools import (
env_value_to_bool,
get_paths_from_environ,
@ -57,14 +65,6 @@ from .anatomy import (
from .config import get_datetime_data
from .vendor_bin_utils import (
get_vendor_bin_path,
get_oiio_tools_path,
get_ffmpeg_tool_path,
ffprobe_streams,
is_oiio_supported
)
from .python_module_tools import (
import_filepath,
modules_from_path,
@ -193,6 +193,7 @@ from .openpype_version import (
terminal = Terminal
__all__ = [
"find_executable",
"get_openpype_execute_args",
"get_pype_execute_args",
"get_linux_launcher_args",

View file

@ -7,7 +7,6 @@ import platform
import collections
import inspect
import subprocess
import distutils.spawn
from abc import ABCMeta, abstractmethod
import six
@ -36,8 +35,10 @@ from .python_module_tools import (
modules_from_path,
classes_from_module
)
from .execute import get_linux_launcher_args
from .execute import (
find_executable,
get_linux_launcher_args
)
_logger = None
@ -647,7 +648,7 @@ class ApplicationExecutable:
def _realpath(self):
"""Check if path is valid executable path."""
# Check for executable in PATH
result = distutils.spawn.find_executable(self.executable_path)
result = find_executable(self.executable_path)
if result is not None:
return result

View file

@ -4,9 +4,9 @@ import subprocess
import platform
import json
import tempfile
import distutils.spawn
from .log import PypeLogger as Logger
from .vendor_bin_utils import find_executable
# MSDN process creation flag (Windows only)
CREATE_NO_WINDOW = 0x08000000
@ -341,7 +341,7 @@ def get_linux_launcher_args(*args):
os.path.dirname(openpype_executable),
filename
)
executable_path = distutils.spawn.find_executable(new_executable)
executable_path = find_executable(new_executable)
if executable_path is None:
return None
launch_args = [executable_path]

View file

@ -3,9 +3,87 @@ import logging
import json
import platform
import subprocess
import distutils
log = logging.getLogger("FFmpeg utils")
log = logging.getLogger("Vendor utils")
def is_file_executable(filepath):
"""Filepath lead to executable file.
Args:
filepath(str): Full path to file.
"""
if not filepath:
return False
if os.path.isfile(filepath):
if os.access(filepath, os.X_OK):
return True
log.info(
"Filepath is not available for execution \"{}\"".format(filepath)
)
return False
def find_executable(executable):
"""Find full path to executable.
Also tries additional extensions if passed executable does not contain one.
Paths where it is looked for executable is defined by 'PATH' environment
variable, 'os.confstr("CS_PATH")' or 'os.defpath'.
Args:
executable(str): Name of executable with or without extension. Can be
path to file.
Returns:
str: Full path to executable with extension (is file).
None: When the executable was not found.
"""
# Skip if passed path is file
if is_file_executable(executable):
return executable
low_platform = platform.system().lower()
_, ext = os.path.splitext(executable)
# Prepare variants for which it will be looked
variants = [executable]
# Add other extension variants only if passed executable does not have one
if not ext:
if low_platform == "windows":
exts = [".exe", ".ps1", ".bat"]
for ext in os.getenv("PATHEXT", "").split(os.pathsep):
ext = ext.lower()
if ext and ext not in exts:
exts.append(ext)
else:
exts = [".sh"]
for ext in exts:
variant = executable + ext
if is_file_executable(variant):
return variant
variants.append(variant)
# Get paths where to look for executable
path_str = os.environ.get("PATH", None)
if path_str is None:
if hasattr(os, "confstr"):
path_str = os.confstr("CS_PATH")
elif hasattr(os, "defpath"):
path_str = os.defpath
if path_str:
paths = path_str.split(os.pathsep)
for path in paths:
for variant in variants:
filepath = os.path.abspath(os.path.join(path, variant))
if is_file_executable(filepath):
return filepath
return None
def get_vendor_bin_path(bin_app):
@ -41,11 +119,7 @@ def get_oiio_tools_path(tool="oiiotool"):
Default is "oiiotool".
"""
oiio_dir = get_vendor_bin_path("oiio")
if platform.system().lower() == "windows" and not tool.lower().endswith(
".exe"
):
tool = "{}.exe".format(tool)
return os.path.join(oiio_dir, tool)
return find_executable(os.path.join(oiio_dir, tool))
def get_ffmpeg_tool_path(tool="ffmpeg"):
@ -61,7 +135,7 @@ def get_ffmpeg_tool_path(tool="ffmpeg"):
ffmpeg_dir = get_vendor_bin_path("ffmpeg")
if platform.system().lower() == "windows":
ffmpeg_dir = os.path.join(ffmpeg_dir, "bin")
return os.path.join(ffmpeg_dir, tool)
return find_executable(os.path.join(ffmpeg_dir, tool))
def ffprobe_streams(path_to_file, logger=None):
@ -122,7 +196,7 @@ def is_oiio_supported():
"""
loaded_path = oiio_path = get_oiio_tools_path()
if oiio_path:
oiio_path = distutils.spawn.find_executable(oiio_path)
oiio_path = find_executable(oiio_path)
if not oiio_path:
log.debug("OIIOTool is not configured or not present at {}".format(
@ -130,3 +204,23 @@ def is_oiio_supported():
))
return False
return True
def get_fps(str_value):
"""Returns (str) value of fps from ffprobe frame format (120/1)"""
if str_value == "0/0":
print("WARNING: Source has \"r_frame_rate\" value set to \"0/0\".")
return "Unknown"
items = str_value.split("/")
if len(items) == 1:
fps = float(items[0])
elif len(items) == 2:
fps = float(items[0]) / float(items[1])
# Check if fps is integer or float number
if int(fps) == fps:
fps = int(fps)
return str(fps)

View file

@ -516,7 +516,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
"""
representations = []
collections, remainders = clique.assemble(exp_files)
bake_renders = instance.get("bakingNukeScripts", [])
# create representation for every collected sequento ce
for collection in collections:
@ -534,9 +533,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
preview = True
break
if bake_renders:
preview = False
# toggle preview on if multipart is on
if instance.get("multipartExr", False):
preview = True
@ -610,16 +606,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
})
self._solve_families(instance, True)
if (bake_renders
and remainder in bake_renders[0]["bakeRenderPath"]):
rep.update({
"fps": instance.get("fps"),
"tags": ["review", "delete"]
})
# solve families with `preview` attributes
self._solve_families(instance, True)
representations.append(rep)
return representations
def _solve_families(self, instance, preview=False):

View file

@ -107,6 +107,10 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin):
explicitly and manually changed the frame list on the Deadline job.
"""
# no frames in file name at all, eg 'renderCompositingMain.withLut.mov'
if not frame_placeholder:
return set([file_name_template])
real_expected_rendered = set()
src_padding_exp = "%0{}d".format(len(frame_placeholder))
for frames in frame_list:
@ -130,14 +134,13 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin):
# There might be cases where clique was unable to collect
# collections in `collect_frames` - thus we capture that case
if frame is None:
self.log.warning("Unable to detect frame from filename: "
"{}".format(file_name))
continue
if frame is not None:
frame_placeholder = "#" * len(frame)
frame_placeholder = "#" * len(frame)
file_name_template = os.path.basename(
file_name.replace(frame, frame_placeholder))
file_name_template = os.path.basename(
file_name.replace(frame, frame_placeholder))
else:
file_name_template = file_name
break
return file_name_template, frame_placeholder

View file

@ -23,8 +23,11 @@ class CollectUsername(pyblish.api.ContextPlugin):
Expects "pype.club" user created on Ftrack and FTRACK_BOT_API_KEY env
var set up.
Resets `context.data["user"] to correctly populate `version.author` and
`representation.context.username`
"""
order = pyblish.api.CollectorOrder - 0.488
order = pyblish.api.CollectorOrder + 0.0015
label = "Collect ftrack username"
hosts = ["webpublisher", "photoshop"]
targets = ["remotepublish", "filespublish", "tvpaint_worker"]
@ -65,3 +68,4 @@ class CollectUsername(pyblish.api.ContextPlugin):
if '@' in burnin_name:
burnin_name = burnin_name[:burnin_name.index('@')]
os.environ["WEBPUBLISH_OPENPYPE_USERNAME"] = burnin_name
context.data["user"] = burnin_name

View file

@ -9,6 +9,7 @@ from .create import (
from .publish import (
PublishValidationError,
PublishXmlValidationError,
KnownPublishError,
OpenPypePyblishPluginMixin
)
@ -23,6 +24,7 @@ __all__ = (
"CreatedInstance",
"PublishValidationError",
"PublishXmlValidationError",
"KnownPublishError",
"OpenPypePyblishPluginMixin"
)

View file

@ -1,20 +1,26 @@
from .publish_plugins import (
PublishValidationError,
PublishXmlValidationError,
KnownPublishError,
OpenPypePyblishPluginMixin
OpenPypePyblishPluginMixin,
)
from .lib import (
DiscoverResult,
publish_plugins_discover
publish_plugins_discover,
load_help_content_from_plugin,
load_help_content_from_filepath,
)
__all__ = (
"PublishValidationError",
"PublishXmlValidationError",
"KnownPublishError",
"OpenPypePyblishPluginMixin",
"DiscoverResult",
"publish_plugins_discover"
"publish_plugins_discover",
"load_help_content_from_plugin",
"load_help_content_from_filepath",
)

View file

@ -1,6 +1,8 @@
import os
import sys
import types
import inspect
import xml.etree.ElementTree
import six
import pyblish.plugin
@ -28,6 +30,60 @@ class DiscoverResult:
self.plugins[item] = value
class HelpContent:
def __init__(self, title, description, detail=None):
self.title = title
self.description = description
self.detail = detail
def load_help_content_from_filepath(filepath):
"""Load help content from xml file.
Xml file may containt errors and warnings.
"""
errors = {}
warnings = {}
output = {
"errors": errors,
"warnings": warnings
}
if not os.path.exists(filepath):
return output
tree = xml.etree.ElementTree.parse(filepath)
root = tree.getroot()
for child in root:
child_id = child.attrib.get("id")
if child_id is None:
continue
# Make sure ID is string
child_id = str(child_id)
title = child.find("title").text
description = child.find("description").text
detail_node = child.find("detail")
detail = None
if detail_node is not None:
detail = detail_node.text
if child.tag == "error":
errors[child_id] = HelpContent(title, description, detail)
elif child.tag == "warning":
warnings[child_id] = HelpContent(title, description, detail)
return output
def load_help_content_from_plugin(plugin):
cls = plugin
if not inspect.isclass(plugin):
cls = plugin.__class__
plugin_filepath = inspect.getfile(cls)
plugin_dir = os.path.dirname(plugin_filepath)
basename = os.path.splitext(os.path.basename(plugin_filepath))[0]
filename = basename + ".xml"
filepath = os.path.join(plugin_dir, "help", filename)
return load_help_content_from_filepath(filepath)
def publish_plugins_discover(paths=None):
"""Find and return available pyblish plug-ins

View file

@ -1,3 +1,6 @@
from .lib import load_help_content_from_plugin
class PublishValidationError(Exception):
"""Validation error happened during publishing.
@ -12,13 +15,34 @@ class PublishValidationError(Exception):
description(str): Detailed description of an error. It is possible
to use Markdown syntax.
"""
def __init__(self, message, title=None, description=None):
def __init__(self, message, title=None, description=None, detail=None):
self.message = message
self.title = title or "< Missing title >"
self.description = description or message
self.detail = detail
super(PublishValidationError, self).__init__(message)
class PublishXmlValidationError(PublishValidationError):
def __init__(
self, plugin, message, key=None, formatting_data=None
):
if key is None:
key = "main"
if not formatting_data:
formatting_data = {}
result = load_help_content_from_plugin(plugin)
content_obj = result["errors"][key]
description = content_obj.description.format(**formatting_data)
detail = content_obj.detail
if detail:
detail = detail.format(**formatting_data)
super(PublishXmlValidationError, self).__init__(
message, content_obj.title, description, detail
)
class KnownPublishError(Exception):
"""Publishing crashed because of known error.

View file

@ -19,7 +19,6 @@ from openpype.lib import (
should_convert_for_ffmpeg,
convert_for_ffmpeg,
get_transcode_temp_directory,
get_transcode_temp_directory
)
import speedcopy
@ -972,16 +971,12 @@ class ExtractReview(pyblish.api.InstancePlugin):
def get_letterbox_filters(
self,
letter_box_def,
input_res_ratio,
output_res_ratio,
pixel_aspect,
scale_factor_by_width,
scale_factor_by_height
output_width,
output_height
):
output = []
ratio = letter_box_def["ratio"]
state = letter_box_def["state"]
fill_color = letter_box_def["fill_color"]
f_red, f_green, f_blue, f_alpha = fill_color
fill_color_hex = "{0:0>2X}{1:0>2X}{2:0>2X}".format(
@ -997,75 +992,129 @@ class ExtractReview(pyblish.api.InstancePlugin):
)
line_color_alpha = float(l_alpha) / 255
if input_res_ratio == output_res_ratio:
ratio /= pixel_aspect
elif input_res_ratio < output_res_ratio:
ratio /= scale_factor_by_width
else:
ratio /= scale_factor_by_height
# test ratios and define if pillar or letter boxes
output_ratio = float(output_width) / float(output_height)
self.log.debug("Output ratio: {} LetterBox ratio: {}".format(
output_ratio, ratio
))
pillar = output_ratio > ratio
need_mask = format(output_ratio, ".3f") != format(ratio, ".3f")
if not need_mask:
return []
if state == "letterbox":
if not pillar:
if fill_color_alpha > 0:
top_box = (
"drawbox=0:0:iw:round((ih-(iw*(1/{})))/2):t=fill:c={}@{}"
).format(ratio, fill_color_hex, fill_color_alpha)
"drawbox=0:0:{width}"
":round(({height}-({width}/{ratio}))/2)"
":t=fill:c={color}@{alpha}"
).format(
width=output_width,
height=output_height,
ratio=ratio,
color=fill_color_hex,
alpha=fill_color_alpha
)
bottom_box = (
"drawbox=0:ih-round((ih-(iw*(1/{0})))/2)"
":iw:round((ih-(iw*(1/{0})))/2):t=fill:c={1}@{2}"
).format(ratio, fill_color_hex, fill_color_alpha)
"drawbox=0"
":{height}-round(({height}-({width}/{ratio}))/2)"
":{width}"
":round(({height}-({width}/{ratio}))/2)"
":t=fill:c={color}@{alpha}"
).format(
width=output_width,
height=output_height,
ratio=ratio,
color=fill_color_hex,
alpha=fill_color_alpha
)
output.extend([top_box, bottom_box])
if line_color_alpha > 0 and line_thickness > 0:
top_line = (
"drawbox=0:round((ih-(iw*(1/{0})))/2)-{1}:iw:{1}:"
"t=fill:c={2}@{3}"
"drawbox=0"
":round(({height}-({width}/{ratio}))/2)-{l_thick}"
":{width}:{l_thick}:t=fill:c={l_color}@{l_alpha}"
).format(
ratio, line_thickness, line_color_hex, line_color_alpha
width=output_width,
height=output_height,
ratio=ratio,
l_thick=line_thickness,
l_color=line_color_hex,
l_alpha=line_color_alpha
)
bottom_line = (
"drawbox=0:ih-round((ih-(iw*(1/{})))/2)"
":iw:{}:t=fill:c={}@{}"
"drawbox=0"
":{height}-round(({height}-({width}/{ratio}))/2)"
":{width}:{l_thick}:t=fill:c={l_color}@{l_alpha}"
).format(
ratio, line_thickness, line_color_hex, line_color_alpha
width=output_width,
height=output_height,
ratio=ratio,
l_thick=line_thickness,
l_color=line_color_hex,
l_alpha=line_color_alpha
)
output.extend([top_line, bottom_line])
elif state == "pillar":
else:
if fill_color_alpha > 0:
left_box = (
"drawbox=0:0:round((iw-(ih*{}))/2):ih:t=fill:c={}@{}"
).format(ratio, fill_color_hex, fill_color_alpha)
"drawbox=0:0"
":round(({width}-({height}*{ratio}))/2)"
":{height}"
":t=fill:c={color}@{alpha}"
).format(
width=output_width,
height=output_height,
ratio=ratio,
color=fill_color_hex,
alpha=fill_color_alpha
)
right_box = (
"drawbox=iw-round((iw-(ih*{0}))/2))"
":0:round((iw-(ih*{0}))/2):ih:t=fill:c={1}@{2}"
).format(ratio, fill_color_hex, fill_color_alpha)
"drawbox="
"{width}-round(({width}-({height}*{ratio}))/2)"
":0"
":round(({width}-({height}*{ratio}))/2)"
":{height}"
":t=fill:c={color}@{alpha}"
).format(
width=output_width,
height=output_height,
ratio=ratio,
color=fill_color_hex,
alpha=fill_color_alpha
)
output.extend([left_box, right_box])
if line_color_alpha > 0 and line_thickness > 0:
left_line = (
"drawbox=round((iw-(ih*{}))/2):0:{}:ih:t=fill:c={}@{}"
"drawbox=round(({width}-({height}*{ratio}))/2)"
":0:{l_thick}:{height}:t=fill:c={l_color}@{l_alpha}"
).format(
ratio, line_thickness, line_color_hex, line_color_alpha
width=output_width,
height=output_height,
ratio=ratio,
l_thick=line_thickness,
l_color=line_color_hex,
l_alpha=line_color_alpha
)
right_line = (
"drawbox=iw-round((iw-(ih*{}))/2))"
":0:{}:ih:t=fill:c={}@{}"
"drawbox={width}-round(({width}-({height}*{ratio}))/2)"
":0:{l_thick}:{height}:t=fill:c={l_color}@{l_alpha}"
).format(
ratio, line_thickness, line_color_hex, line_color_alpha
width=output_width,
height=output_height,
ratio=ratio,
l_thick=line_thickness,
l_color=line_color_hex,
l_alpha=line_color_alpha
)
output.extend([left_line, right_line])
else:
raise ValueError(
"Letterbox state \"{}\" is not recognized".format(state)
)
return output
def rescaling_filters(self, temp_data, output_def, new_repre):
@ -1079,6 +1128,20 @@ class ExtractReview(pyblish.api.InstancePlugin):
"""
filters = []
# if reformat input video file is already reforamted from upstream
reformat_in_baking = bool("reformated" in new_repre["tags"])
self.log.debug("reformat_in_baking: `{}`".format(reformat_in_baking))
# Get instance data
pixel_aspect = temp_data["pixel_aspect"]
if reformat_in_baking:
self.log.debug((
"Using resolution from input. It is already "
"reformated from upstream process"
))
pixel_aspect = 1
# NOTE Skipped using instance's resolution
full_input_path_single_file = temp_data["full_input_path_single_file"]
try:
@ -1141,12 +1204,6 @@ class ExtractReview(pyblish.api.InstancePlugin):
output_width = input_width
output_height = input_height
letter_box_def = output_def["letter_box"]
letter_box_enabled = letter_box_def["enabled"]
# Get instance data
pixel_aspect = temp_data["pixel_aspect"]
# Make sure input width and height is not an odd number
input_width_is_odd = bool(input_width % 2 != 0)
input_height_is_odd = bool(input_height % 2 != 0)
@ -1171,9 +1228,6 @@ class ExtractReview(pyblish.api.InstancePlugin):
self.log.debug("input_width: `{}`".format(input_width))
self.log.debug("input_height: `{}`".format(input_height))
reformat_in_baking = bool("reformated" in new_repre["tags"])
self.log.debug("reformat_in_baking: `{}`".format(reformat_in_baking))
# Use instance resolution if output definition has not set it.
if output_width is None or output_height is None:
output_width = temp_data["resolution_width"]
@ -1185,17 +1239,6 @@ class ExtractReview(pyblish.api.InstancePlugin):
output_width = input_width
output_height = input_height
if reformat_in_baking:
self.log.debug((
"Using resolution from input. It is already "
"reformated from baking process"
))
output_width = input_width
output_height = input_height
pixel_aspect = 1
new_repre["resolutionWidth"] = input_width
new_repre["resolutionHeight"] = input_height
output_width = int(output_width)
output_height = int(output_height)
@ -1219,6 +1262,9 @@ class ExtractReview(pyblish.api.InstancePlugin):
"Output resolution is {}x{}".format(output_width, output_height)
)
letter_box_def = output_def["letter_box"]
letter_box_enabled = letter_box_def["enabled"]
# Skip processing if resolution is same as input's and letterbox is
# not set
if (
@ -1262,25 +1308,6 @@ class ExtractReview(pyblish.api.InstancePlugin):
"scale_factor_by_height: `{}`".format(scale_factor_by_height)
)
# letter_box
if letter_box_enabled:
filters.extend([
"scale={}x{}:flags=lanczos".format(
output_width, output_height
),
"setsar=1"
])
filters.extend(
self.get_letterbox_filters(
letter_box_def,
input_res_ratio,
output_res_ratio,
pixel_aspect,
scale_factor_by_width,
scale_factor_by_height
)
)
# scaling none square pixels and 1920 width
if (
input_height != output_height
@ -1319,6 +1346,16 @@ class ExtractReview(pyblish.api.InstancePlugin):
"setsar=1"
])
# letter_box
if letter_box_enabled:
filters.extend(
self.get_letterbox_filters(
letter_box_def,
output_width,
output_height
)
)
new_repre["resolutionWidth"] = output_width
new_repre["resolutionHeight"] = output_height

View file

@ -6,6 +6,7 @@ import platform
import json
import opentimelineio_contrib.adapters.ffmpeg_burnins as ffmpeg_burnins
import openpype.lib
from openpype.lib.vendor_bin_utils import get_fps
ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg")
@ -50,25 +51,6 @@ def _get_ffprobe_data(source):
return json.loads(out)
def get_fps(str_value):
if str_value == "0/0":
print("WARNING: Source has \"r_frame_rate\" value set to \"0/0\".")
return "Unknown"
items = str_value.split("/")
if len(items) == 1:
fps = float(items[0])
elif len(items) == 2:
fps = float(items[0]) / float(items[1])
# Check if fps is integer or float number
if int(fps) == fps:
fps = int(fps)
return str(fps)
def _prores_codec_args(stream_data, source_ffmpeg_cmd):
output = []

View file

@ -107,7 +107,6 @@
"letter_box": {
"enabled": false,
"ratio": 0.0,
"state": "letterbox",
"fill_color": [
0,
0,

View file

@ -366,19 +366,6 @@
"minimum": 0,
"maximum": 10000
},
{
"key": "state",
"label": "Type",
"type": "enum",
"enum_items": [
{
"letterbox": "Letterbox"
},
{
"pillar": "Pillar"
}
]
},
{
"type": "color",
"label": "Fill Color",

View file

@ -1,10 +0,0 @@
from .app import (
show,
cli
)
__all__ = [
"show",
"cli",
]

View file

@ -1,5 +0,0 @@
from . import cli
if __name__ == '__main__':
import sys
sys.exit(cli(sys.argv[1:]))

View file

@ -1,654 +0,0 @@
import os
import sys
from subprocess import Popen
import ftrack_api
from Qt import QtWidgets, QtCore
from openpype import style
from openpype.api import get_current_project_settings
from openpype.lib.avalon_context import update_current_task
from openpype.tools.utils.lib import qt_app_context
from avalon import io, api, schema
from . import widget, model
module = sys.modules[__name__]
module.window = None
class Window(QtWidgets.QDialog):
"""Asset creator interface
"""
def __init__(self, parent=None, context=None):
super(Window, self).__init__(parent)
self.context = context
project_name = io.active_project()
self.setWindowTitle("Asset creator ({0})".format(project_name))
self.setFocusPolicy(QtCore.Qt.StrongFocus)
self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
# Validators
self.valid_parent = False
self.session = None
# assets widget
assets_widget = QtWidgets.QWidget()
assets_widget.setContentsMargins(0, 0, 0, 0)
assets_layout = QtWidgets.QVBoxLayout(assets_widget)
assets = widget.AssetWidget()
assets.view.setSelectionMode(assets.view.ExtendedSelection)
assets_layout.addWidget(assets)
# Outlink
label_outlink = QtWidgets.QLabel("Outlink:")
input_outlink = QtWidgets.QLineEdit()
input_outlink.setReadOnly(True)
input_outlink.setStyleSheet("background-color: #333333;")
checkbox_outlink = QtWidgets.QCheckBox("Use outlink")
# Parent
label_parent = QtWidgets.QLabel("*Parent:")
input_parent = QtWidgets.QLineEdit()
input_parent.setReadOnly(True)
input_parent.setStyleSheet("background-color: #333333;")
# Name
label_name = QtWidgets.QLabel("*Name:")
input_name = QtWidgets.QLineEdit()
input_name.setPlaceholderText("<asset name>")
# Asset Build
label_assetbuild = QtWidgets.QLabel("Asset Build:")
combo_assetbuilt = QtWidgets.QComboBox()
# Task template
label_task_template = QtWidgets.QLabel("Task template:")
combo_task_template = QtWidgets.QComboBox()
# Info widget
info_widget = QtWidgets.QWidget()
info_widget.setContentsMargins(10, 10, 10, 10)
info_layout = QtWidgets.QVBoxLayout(info_widget)
# Inputs widget
inputs_widget = QtWidgets.QWidget()
inputs_widget.setContentsMargins(0, 0, 0, 0)
inputs_layout = QtWidgets.QFormLayout(inputs_widget)
inputs_layout.addRow(label_outlink, input_outlink)
inputs_layout.addRow(None, checkbox_outlink)
inputs_layout.addRow(label_parent, input_parent)
inputs_layout.addRow(label_name, input_name)
inputs_layout.addRow(label_assetbuild, combo_assetbuilt)
inputs_layout.addRow(label_task_template, combo_task_template)
# Add button
btns_widget = QtWidgets.QWidget()
btns_widget.setContentsMargins(0, 0, 0, 0)
btn_layout = QtWidgets.QHBoxLayout(btns_widget)
btn_create_asset = QtWidgets.QPushButton("Create asset")
btn_create_asset.setToolTip(
"Creates all necessary components for asset"
)
checkbox_app = None
if self.context is not None:
checkbox_app = QtWidgets.QCheckBox("Open {}".format(
self.context.capitalize())
)
btn_layout.addWidget(checkbox_app)
btn_layout.addWidget(btn_create_asset)
task_view = QtWidgets.QTreeView()
task_view.setIndentation(0)
task_model = model.TasksModel()
task_view.setModel(task_model)
info_layout.addWidget(inputs_widget)
info_layout.addWidget(task_view)
info_layout.addWidget(btns_widget)
# Body
body = QtWidgets.QSplitter()
body.setContentsMargins(0, 0, 0, 0)
body.setSizePolicy(QtWidgets.QSizePolicy.Expanding,
QtWidgets.QSizePolicy.Expanding)
body.setOrientation(QtCore.Qt.Horizontal)
body.addWidget(assets_widget)
body.addWidget(info_widget)
body.setStretchFactor(0, 100)
body.setStretchFactor(1, 150)
# statusbar
message = QtWidgets.QLabel()
message.setFixedHeight(20)
statusbar = QtWidgets.QWidget()
layout = QtWidgets.QHBoxLayout(statusbar)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(message)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(body)
layout.addWidget(statusbar)
self.data = {
"label": {
"message": message,
},
"view": {
"tasks": task_view
},
"model": {
"assets": assets,
"tasks": task_model
},
"inputs": {
"outlink": input_outlink,
"outlink_cb": checkbox_outlink,
"parent": input_parent,
"name": input_name,
"assetbuild": combo_assetbuilt,
"tasktemplate": combo_task_template,
"open_app": checkbox_app
},
"buttons": {
"create_asset": btn_create_asset
}
}
# signals
btn_create_asset.clicked.connect(self.create_asset)
assets.selection_changed.connect(self.on_asset_changed)
input_name.textChanged.connect(self.on_asset_name_change)
checkbox_outlink.toggled.connect(self.on_outlink_checkbox_change)
combo_task_template.currentTextChanged.connect(
self.on_task_template_changed
)
if self.context is not None:
checkbox_app.toggled.connect(self.on_app_checkbox_change)
# on start
self.on_start()
self.resize(600, 500)
self.echo("Connected to project: {0}".format(project_name))
def open_app(self):
if self.context == 'maya':
Popen("maya")
else:
message = QtWidgets.QMessageBox(self)
message.setWindowTitle("App is not set")
message.setIcon(QtWidgets.QMessageBox.Critical)
message.show()
def on_start(self):
project_name = io.Session['AVALON_PROJECT']
project_query = 'Project where full_name is "{}"'.format(project_name)
if self.session is None:
session = ftrack_api.Session()
self.session = session
else:
session = self.session
ft_project = session.query(project_query).one()
schema_name = ft_project['project_schema']['name']
# Load config
schemas_items = get_current_project_settings().get('ftrack', {}).get(
'project_schemas', {}
)
# Get info if it is silo project
self.silos = io.distinct("silo")
if self.silos and None in self.silos:
self.silos = None
key = "default"
if schema_name in schemas_items:
key = schema_name
self.config_data = schemas_items[key]
# set outlink
input_outlink = self.data['inputs']['outlink']
checkbox_outlink = self.data['inputs']['outlink_cb']
outlink_text = io.Session.get('AVALON_ASSET', '')
checkbox_outlink.setChecked(True)
if outlink_text == '':
outlink_text = '< No context >'
checkbox_outlink.setChecked(False)
checkbox_outlink.hide()
input_outlink.setText(outlink_text)
# load asset build types
self.load_assetbuild_types()
# Load task templates
self.load_task_templates()
self.data["model"]["assets"].refresh()
self.on_asset_changed()
def create_asset(self):
name_input = self.data['inputs']['name']
name = name_input.text()
test_name = name.replace(' ', '')
error_message = None
message = QtWidgets.QMessageBox(self)
message.setWindowTitle("Some errors have occurred")
message.setIcon(QtWidgets.QMessageBox.Critical)
# TODO: show error messages on any error
if self.valid_parent is not True and test_name == '':
error_message = "Name is not set and Parent is not selected"
elif self.valid_parent is not True:
error_message = "Parent is not selected"
elif test_name == '':
error_message = "Name is not set"
if error_message is not None:
message.setText(error_message)
message.show()
return
test_name_exists = io.find({
'type': 'asset',
'name': name
})
existing_assets = [x for x in test_name_exists]
if len(existing_assets) > 0:
message.setText("Entered Asset name is occupied")
message.show()
return
checkbox_app = self.data['inputs']['open_app']
if checkbox_app is not None and checkbox_app.isChecked() is True:
task_view = self.data["view"]["tasks"]
task_model = self.data["model"]["tasks"]
try:
index = task_view.selectedIndexes()[0]
task_name = task_model.itemData(index)[0]
except Exception:
message.setText("Please select task")
message.show()
return
# Get ftrack session
if self.session is None:
session = ftrack_api.Session()
self.session = session
else:
session = self.session
# Get Ftrack project entity
project_name = io.Session['AVALON_PROJECT']
project_query = 'Project where full_name is "{}"'.format(project_name)
try:
ft_project = session.query(project_query).one()
except Exception:
message.setText("Ftrack project was not found")
message.show()
return
# Get Ftrack entity of parent
ft_parent = None
assets_model = self.data["model"]["assets"]
selected = assets_model.get_selected_assets()
parent = io.find_one({"_id": selected[0], "type": "asset"})
asset_id = parent.get('data', {}).get('ftrackId', None)
asset_entity_type = parent.get('data', {}).get('entityType', None)
asset_query = '{} where id is "{}"'
if asset_id is not None and asset_entity_type is not None:
try:
ft_parent = session.query(asset_query.format(
asset_entity_type, asset_id)
).one()
except Exception:
ft_parent = None
if ft_parent is None:
ft_parent = self.get_ftrack_asset(parent, ft_project)
if ft_parent is None:
message.setText("Parent's Ftrack entity was not found")
message.show()
return
asset_build_combo = self.data['inputs']['assetbuild']
asset_type_name = asset_build_combo.currentText()
asset_type_query = 'Type where name is "{}"'.format(asset_type_name)
try:
asset_type = session.query(asset_type_query).one()
except Exception:
message.setText("Selected Asset Build type does not exists")
message.show()
return
for children in ft_parent['children']:
if children['name'] == name:
message.setText("Entered Asset name is occupied")
message.show()
return
task_template_combo = self.data['inputs']['tasktemplate']
task_template = task_template_combo.currentText()
tasks = []
for template in self.config_data['task_templates']:
if template['name'] == task_template:
tasks = template['task_types']
break
available_task_types = []
task_types = ft_project['project_schema']['_task_type_schema']
for task_type in task_types['types']:
available_task_types.append(task_type['name'])
not_possible_tasks = []
for task in tasks:
if task not in available_task_types:
not_possible_tasks.append(task)
if len(not_possible_tasks) != 0:
message.setText((
"These Task types weren't found"
" in Ftrack project schema:\n{}").format(
', '.join(not_possible_tasks))
)
message.show()
return
# Create asset build
asset_build_data = {
'name': name,
'project_id': ft_project['id'],
'parent_id': ft_parent['id'],
'type': asset_type
}
new_entity = session.create('AssetBuild', asset_build_data)
task_data = {
'project_id': ft_project['id'],
'parent_id': new_entity['id']
}
for task in tasks:
type = session.query('Type where name is "{}"'.format(task)).one()
task_data['type_id'] = type['id']
task_data['name'] = task
session.create('Task', task_data)
av_project = io.find_one({'type': 'project'})
hiearchy_items = []
hiearchy_items.extend(self.get_avalon_parent(parent))
hiearchy_items.append(parent['name'])
hierarchy = os.path.sep.join(hiearchy_items)
new_asset_data = {
'ftrackId': new_entity['id'],
'entityType': new_entity.entity_type,
'visualParent': parent['_id'],
'tasks': tasks,
'parents': hiearchy_items,
'hierarchy': hierarchy
}
new_asset_info = {
'parent': av_project['_id'],
'name': name,
'schema': "openpype:asset-3.0",
'type': 'asset',
'data': new_asset_data
}
# Backwards compatibility (add silo from parent if is silo project)
if self.silos:
new_asset_info["silo"] = parent["silo"]
try:
schema.validate(new_asset_info)
except Exception:
message.setText((
'Asset information are not valid'
' to create asset in avalon database'
))
message.show()
session.rollback()
return
io.insert_one(new_asset_info)
session.commit()
outlink_cb = self.data['inputs']['outlink_cb']
if outlink_cb.isChecked() is True:
outlink_input = self.data['inputs']['outlink']
outlink_name = outlink_input.text()
outlink_asset = io.find_one({
'type': 'asset',
'name': outlink_name
})
outlink_ft_id = outlink_asset.get('data', {}).get('ftrackId', None)
outlink_entity_type = outlink_asset.get(
'data', {}
).get('entityType', None)
if outlink_ft_id is not None and outlink_entity_type is not None:
try:
outlink_entity = session.query(asset_query.format()).one()
except Exception:
outlink_entity = None
if outlink_entity is None:
outlink_entity = self.get_ftrack_asset(
outlink_asset, ft_project
)
if outlink_entity is None:
message.setText("Outlink's Ftrack entity was not found")
message.show()
return
link_data = {
'from_id': new_entity['id'],
'to_id': outlink_entity['id']
}
session.create('TypedContextLink', link_data)
session.commit()
if checkbox_app is not None and checkbox_app.isChecked() is True:
origin_asset = api.Session.get('AVALON_ASSET', None)
origin_task = api.Session.get('AVALON_TASK', None)
asset_name = name
task_view = self.data["view"]["tasks"]
task_model = self.data["model"]["tasks"]
try:
index = task_view.selectedIndexes()[0]
except Exception:
message.setText("No task is selected. App won't be launched")
message.show()
return
task_name = task_model.itemData(index)[0]
try:
update_current_task(task=task_name, asset=asset_name)
self.open_app()
finally:
if origin_task is not None and origin_asset is not None:
update_current_task(
task=origin_task, asset=origin_asset
)
message.setWindowTitle("Asset Created")
message.setText("Asset Created successfully")
message.setIcon(QtWidgets.QMessageBox.Information)
message.show()
def get_ftrack_asset(self, asset, ft_project):
parenthood = []
parenthood.extend(self.get_avalon_parent(asset))
parenthood.append(asset['name'])
parenthood = list(reversed(parenthood))
output_entity = None
ft_entity = ft_project
index = len(parenthood) - 1
while True:
name = parenthood[index]
found = False
for children in ft_entity['children']:
if children['name'] == name:
ft_entity = children
found = True
break
if found is False:
return None
if index == 0:
output_entity = ft_entity
break
index -= 1
return output_entity
def get_avalon_parent(self, entity):
parent_id = entity['data']['visualParent']
parents = []
if parent_id is not None:
parent = io.find_one({'_id': parent_id})
parents.extend(self.get_avalon_parent(parent))
parents.append(parent['name'])
return parents
def echo(self, message):
widget = self.data["label"]["message"]
widget.setText(str(message))
QtCore.QTimer.singleShot(5000, lambda: widget.setText(""))
print(message)
def load_task_templates(self):
templates = self.config_data.get('task_templates', [])
all_names = []
for template in templates:
all_names.append(template['name'])
tt_combobox = self.data['inputs']['tasktemplate']
tt_combobox.clear()
tt_combobox.addItems(all_names)
def load_assetbuild_types(self):
types = []
schemas = self.config_data.get('schemas', [])
for _schema in schemas:
if _schema['object_type'] == 'Asset Build':
types = _schema['task_types']
break
ab_combobox = self.data['inputs']['assetbuild']
ab_combobox.clear()
ab_combobox.addItems(types)
def on_app_checkbox_change(self):
task_model = self.data['model']['tasks']
app_checkbox = self.data['inputs']['open_app']
if app_checkbox.isChecked() is True:
task_model.selectable = True
else:
task_model.selectable = False
def on_outlink_checkbox_change(self):
checkbox_outlink = self.data['inputs']['outlink_cb']
outlink_input = self.data['inputs']['outlink']
if checkbox_outlink.isChecked() is True:
outlink_text = io.Session['AVALON_ASSET']
else:
outlink_text = '< Outlinks won\'t be set >'
outlink_input.setText(outlink_text)
def on_task_template_changed(self):
combobox = self.data['inputs']['tasktemplate']
task_model = self.data['model']['tasks']
name = combobox.currentText()
tasks = []
for template in self.config_data['task_templates']:
if template['name'] == name:
tasks = template['task_types']
break
task_model.set_tasks(tasks)
def on_asset_changed(self):
"""Callback on asset selection changed
This updates the task view.
"""
assets_model = self.data["model"]["assets"]
parent_input = self.data['inputs']['parent']
selected = assets_model.get_selected_assets()
self.valid_parent = False
if len(selected) > 1:
parent_input.setText('< Please select only one asset! >')
elif len(selected) == 1:
if isinstance(selected[0], io.ObjectId):
self.valid_parent = True
asset = io.find_one({"_id": selected[0], "type": "asset"})
parent_input.setText(asset['name'])
else:
parent_input.setText('< Selected invalid parent(silo) >')
else:
parent_input.setText('< Nothing is selected >')
self.creatability_check()
def on_asset_name_change(self):
self.creatability_check()
def creatability_check(self):
name_input = self.data['inputs']['name']
name = str(name_input.text()).strip()
creatable = False
if name and self.valid_parent:
creatable = True
self.data["buttons"]["create_asset"].setEnabled(creatable)
def show(parent=None, debug=False, context=None):
"""Display Loader GUI
Arguments:
debug (bool, optional): Run loader in debug-mode,
defaults to False
"""
try:
module.window.close()
del module.window
except (RuntimeError, AttributeError):
pass
if debug is True:
io.install()
with qt_app_context():
window = Window(parent, context)
window.setStyleSheet(style.load_stylesheet())
window.show()
module.window = window
def cli(args):
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("project")
parser.add_argument("asset")
args = parser.parse_args(args)
project = args.project
asset = args.asset
io.install()
api.Session["AVALON_PROJECT"] = project
if asset != '':
api.Session["AVALON_ASSET"] = asset
show()

View file

@ -1,310 +0,0 @@
import re
import logging
from Qt import QtCore, QtWidgets
from avalon.vendor import qtawesome
from avalon import io
from avalon import style
log = logging.getLogger(__name__)
class Item(dict):
"""An item that can be represented in a tree view using `TreeModel`.
The item can store data just like a regular dictionary.
>>> data = {"name": "John", "score": 10}
>>> item = Item(data)
>>> assert item["name"] == "John"
"""
def __init__(self, data=None):
super(Item, self).__init__()
self._children = list()
self._parent = None
if data is not None:
assert isinstance(data, dict)
self.update(data)
def childCount(self):
return len(self._children)
def child(self, row):
if row >= len(self._children):
log.warning("Invalid row as child: {0}".format(row))
return
return self._children[row]
def children(self):
return self._children
def parent(self):
return self._parent
def row(self):
"""
Returns:
int: Index of this item under parent"""
if self._parent is not None:
siblings = self.parent().children()
return siblings.index(self)
def add_child(self, child):
"""Add a child to this item"""
child._parent = self
self._children.append(child)
class TreeModel(QtCore.QAbstractItemModel):
Columns = list()
ItemRole = QtCore.Qt.UserRole + 1
def __init__(self, parent=None):
super(TreeModel, self).__init__(parent)
self._root_item = Item()
def rowCount(self, parent):
if parent.isValid():
item = parent.internalPointer()
else:
item = self._root_item
return item.childCount()
def columnCount(self, parent):
return len(self.Columns)
def data(self, index, role):
if not index.isValid():
return None
if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole:
item = index.internalPointer()
column = index.column()
key = self.Columns[column]
return item.get(key, None)
if role == self.ItemRole:
return index.internalPointer()
def setData(self, index, value, role=QtCore.Qt.EditRole):
"""Change the data on the items.
Returns:
bool: Whether the edit was successful
"""
if index.isValid():
if role == QtCore.Qt.EditRole:
item = index.internalPointer()
column = index.column()
key = self.Columns[column]
item[key] = value
# passing `list()` for PyQt5 (see PYSIDE-462)
self.dataChanged.emit(index, index, list())
# must return true if successful
return True
return False
def setColumns(self, keys):
assert isinstance(keys, (list, tuple))
self.Columns = keys
def headerData(self, section, orientation, role):
if role == QtCore.Qt.DisplayRole:
if section < len(self.Columns):
return self.Columns[section]
super(TreeModel, self).headerData(section, orientation, role)
def flags(self, index):
flags = QtCore.Qt.ItemIsEnabled
item = index.internalPointer()
if item.get("enabled", True):
flags |= QtCore.Qt.ItemIsSelectable
return flags
def parent(self, index):
item = index.internalPointer()
parent_item = item.parent()
# If it has no parents we return invalid
if parent_item == self._root_item or not parent_item:
return QtCore.QModelIndex()
return self.createIndex(parent_item.row(), 0, parent_item)
def index(self, row, column, parent):
"""Return index for row/column under parent"""
if not parent.isValid():
parent_item = self._root_item
else:
parent_item = parent.internalPointer()
child_item = parent_item.child(row)
if child_item:
return self.createIndex(row, column, child_item)
else:
return QtCore.QModelIndex()
def add_child(self, item, parent=None):
if parent is None:
parent = self._root_item
parent.add_child(item)
def column_name(self, column):
"""Return column key by index"""
if column < len(self.Columns):
return self.Columns[column]
def clear(self):
self.beginResetModel()
self._root_item = Item()
self.endResetModel()
class TasksModel(TreeModel):
"""A model listing the tasks combined for a list of assets"""
Columns = ["Tasks"]
def __init__(self):
super(TasksModel, self).__init__()
self._num_assets = 0
self._icons = {
"__default__": qtawesome.icon("fa.male",
color=style.colors.default),
"__no_task__": qtawesome.icon("fa.exclamation-circle",
color=style.colors.mid)
}
self._get_task_icons()
def _get_task_icons(self):
# Get the project configured icons from database
project = io.find_one({"type": "project"})
tasks = project["config"].get("tasks", [])
for task in tasks:
icon_name = task.get("icon", None)
if icon_name:
icon = qtawesome.icon("fa.{}".format(icon_name),
color=style.colors.default)
self._icons[task["name"]] = icon
def set_tasks(self, tasks):
"""Set assets to track by their database id
Arguments:
asset_ids (list): List of asset ids.
"""
self.clear()
# let cleared task view if no tasks are available
if len(tasks) == 0:
return
self.beginResetModel()
icon = self._icons["__default__"]
for task in tasks:
item = Item({
"Tasks": task,
"icon": icon
})
self.add_child(item)
self.endResetModel()
def flags(self, index):
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
def headerData(self, section, orientation, role):
# Override header for count column to show amount of assets
# it is listing the tasks for
if role == QtCore.Qt.DisplayRole:
if orientation == QtCore.Qt.Horizontal:
if section == 1: # count column
return "count ({0})".format(self._num_assets)
return super(TasksModel, self).headerData(section, orientation, role)
def data(self, index, role):
if not index.isValid():
return
# Add icon to the first column
if role == QtCore.Qt.DecorationRole:
if index.column() == 0:
return index.internalPointer()["icon"]
return super(TasksModel, self).data(index, role)
class DeselectableTreeView(QtWidgets.QTreeView):
"""A tree view that deselects on clicking on an empty area in the view"""
def mousePressEvent(self, event):
index = self.indexAt(event.pos())
if not index.isValid():
# clear the selection
self.clearSelection()
# clear the current index
self.setCurrentIndex(QtCore.QModelIndex())
QtWidgets.QTreeView.mousePressEvent(self, event)
class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel):
"""Filters to the regex if any of the children matches allow parent"""
def filterAcceptsRow(self, row, parent):
regex = self.filterRegExp()
if not regex.isEmpty():
pattern = regex.pattern()
model = self.sourceModel()
source_index = model.index(row, self.filterKeyColumn(), parent)
if source_index.isValid():
# Check current index itself
key = model.data(source_index, self.filterRole())
if re.search(pattern, key, re.IGNORECASE):
return True
# Check children
rows = model.rowCount(source_index)
for i in range(rows):
if self.filterAcceptsRow(i, source_index):
return True
# Otherwise filter it
return False
return super(RecursiveSortFilterProxyModel,
self).filterAcceptsRow(row, parent)

View file

@ -1,448 +0,0 @@
import logging
import contextlib
import collections
from avalon.vendor import qtawesome
from Qt import QtWidgets, QtCore, QtGui
from avalon import style, io
from .model import (
TreeModel,
Item,
RecursiveSortFilterProxyModel,
DeselectableTreeView
)
log = logging.getLogger(__name__)
def _iter_model_rows(model,
column,
include_root=False):
"""Iterate over all row indices in a model"""
indices = [QtCore.QModelIndex()] # start iteration at root
for index in indices:
# Add children to the iterations
child_rows = model.rowCount(index)
for child_row in range(child_rows):
child_index = model.index(child_row, column, index)
indices.append(child_index)
if not include_root and not index.isValid():
continue
yield index
@contextlib.contextmanager
def preserve_expanded_rows(tree_view,
column=0,
role=QtCore.Qt.DisplayRole):
"""Preserves expanded row in QTreeView by column's data role.
This function is created to maintain the expand vs collapse status of
the model items. When refresh is triggered the items which are expanded
will stay expanded and vice versa.
Arguments:
tree_view (QWidgets.QTreeView): the tree view which is
nested in the application
column (int): the column to retrieve the data from
role (int): the role which dictates what will be returned
Returns:
None
"""
model = tree_view.model()
expanded = set()
for index in _iter_model_rows(model,
column=column,
include_root=False):
if tree_view.isExpanded(index):
value = index.data(role)
expanded.add(value)
try:
yield
finally:
if not expanded:
return
for index in _iter_model_rows(model,
column=column,
include_root=False):
value = index.data(role)
state = value in expanded
if state:
tree_view.expand(index)
else:
tree_view.collapse(index)
@contextlib.contextmanager
def preserve_selection(tree_view,
column=0,
role=QtCore.Qt.DisplayRole,
current_index=True):
"""Preserves row selection in QTreeView by column's data role.
This function is created to maintain the selection status of
the model items. When refresh is triggered the items which are expanded
will stay expanded and vice versa.
tree_view (QWidgets.QTreeView): the tree view nested in the application
column (int): the column to retrieve the data from
role (int): the role which dictates what will be returned
Returns:
None
"""
model = tree_view.model()
selection_model = tree_view.selectionModel()
flags = selection_model.Select | selection_model.Rows
if current_index:
current_index_value = tree_view.currentIndex().data(role)
else:
current_index_value = None
selected_rows = selection_model.selectedRows()
if not selected_rows:
yield
return
selected = set(row.data(role) for row in selected_rows)
try:
yield
finally:
if not selected:
return
# Go through all indices, select the ones with similar data
for index in _iter_model_rows(model,
column=column,
include_root=False):
value = index.data(role)
state = value in selected
if state:
tree_view.scrollTo(index) # Ensure item is visible
selection_model.select(index, flags)
if current_index_value and value == current_index_value:
tree_view.setCurrentIndex(index)
class AssetModel(TreeModel):
"""A model listing assets in the silo in the active project.
The assets are displayed in a treeview, they are visually parented by
a `visualParent` field in the database containing an `_id` to a parent
asset.
"""
Columns = ["label"]
Name = 0
Deprecated = 2
ObjectId = 3
DocumentRole = QtCore.Qt.UserRole + 2
ObjectIdRole = QtCore.Qt.UserRole + 3
def __init__(self, parent=None):
super(AssetModel, self).__init__(parent=parent)
self.refresh()
def _add_hierarchy(self, assets, parent=None, silos=None):
"""Add the assets that are related to the parent as children items.
This method does *not* query the database. These instead are queried
in a single batch upfront as an optimization to reduce database
queries. Resulting in up to 10x speed increase.
Args:
assets (dict): All assets in the currently active silo stored
by key/value
Returns:
None
"""
if silos:
# WARNING: Silo item "_id" is set to silo value
# mainly because GUI issue with preserve selection and expanded row
# and because of easier hierarchy parenting (in "assets")
for silo in silos:
item = Item({
"_id": silo,
"name": silo,
"label": silo,
"type": "silo"
})
self.add_child(item, parent=parent)
self._add_hierarchy(assets, parent=item)
parent_id = parent["_id"] if parent else None
current_assets = assets.get(parent_id, list())
for asset in current_assets:
# get label from data, otherwise use name
data = asset.get("data", {})
label = data.get("label", asset["name"])
tags = data.get("tags", [])
# store for the asset for optimization
deprecated = "deprecated" in tags
item = Item({
"_id": asset["_id"],
"name": asset["name"],
"label": label,
"type": asset["type"],
"tags": ", ".join(tags),
"deprecated": deprecated,
"_document": asset
})
self.add_child(item, parent=parent)
# Add asset's children recursively if it has children
if asset["_id"] in assets:
self._add_hierarchy(assets, parent=item)
def refresh(self):
"""Refresh the data for the model."""
self.clear()
self.beginResetModel()
# Get all assets in current silo sorted by name
db_assets = io.find({"type": "asset"}).sort("name", 1)
silos = db_assets.distinct("silo") or None
# if any silo is set to None then it's expected it should not be used
if silos and None in silos:
silos = None
# Group the assets by their visual parent's id
assets_by_parent = collections.defaultdict(list)
for asset in db_assets:
parent_id = (
asset.get("data", {}).get("visualParent") or
asset.get("silo")
)
assets_by_parent[parent_id].append(asset)
# Build the hierarchical tree items recursively
self._add_hierarchy(
assets_by_parent,
parent=None,
silos=silos
)
self.endResetModel()
def flags(self, index):
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
def data(self, index, role):
if not index.isValid():
return
item = index.internalPointer()
if role == QtCore.Qt.DecorationRole: # icon
column = index.column()
if column == self.Name:
# Allow a custom icon and custom icon color to be defined
data = item.get("_document", {}).get("data", {})
icon = data.get("icon", None)
if icon is None and item.get("type") == "silo":
icon = "database"
color = data.get("color", style.colors.default)
if icon is None:
# Use default icons if no custom one is specified.
# If it has children show a full folder, otherwise
# show an open folder
has_children = self.rowCount(index) > 0
icon = "folder" if has_children else "folder-o"
# Make the color darker when the asset is deprecated
if item.get("deprecated", False):
color = QtGui.QColor(color).darker(250)
try:
key = "fa.{0}".format(icon) # font-awesome key
icon = qtawesome.icon(key, color=color)
return icon
except Exception as exception:
# Log an error message instead of erroring out completely
# when the icon couldn't be created (e.g. invalid name)
log.error(exception)
return
if role == QtCore.Qt.ForegroundRole: # font color
if "deprecated" in item.get("tags", []):
return QtGui.QColor(style.colors.light).darker(250)
if role == self.ObjectIdRole:
return item.get("_id", None)
if role == self.DocumentRole:
return item.get("_document", None)
return super(AssetModel, self).data(index, role)
class AssetWidget(QtWidgets.QWidget):
"""A Widget to display a tree of assets with filter
To list the assets of the active project:
>>> # widget = AssetWidget()
>>> # widget.refresh()
>>> # widget.show()
"""
assets_refreshed = QtCore.Signal() # on model refresh
selection_changed = QtCore.Signal() # on view selection change
current_changed = QtCore.Signal() # on view current index change
def __init__(self, parent=None):
super(AssetWidget, self).__init__(parent=parent)
self.setContentsMargins(0, 0, 0, 0)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
# Tree View
model = AssetModel(self)
proxy = RecursiveSortFilterProxyModel()
proxy.setSourceModel(model)
proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
view = DeselectableTreeView()
view.setIndentation(15)
view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
view.setHeaderHidden(True)
view.setModel(proxy)
# Header
header = QtWidgets.QHBoxLayout()
icon = qtawesome.icon("fa.refresh", color=style.colors.light)
refresh = QtWidgets.QPushButton(icon, "")
refresh.setToolTip("Refresh items")
filter = QtWidgets.QLineEdit()
filter.textChanged.connect(proxy.setFilterFixedString)
filter.setPlaceholderText("Filter assets..")
header.addWidget(filter)
header.addWidget(refresh)
# Layout
layout.addLayout(header)
layout.addWidget(view)
# Signals/Slots
selection = view.selectionModel()
selection.selectionChanged.connect(self.selection_changed)
selection.currentChanged.connect(self.current_changed)
refresh.clicked.connect(self.refresh)
self.refreshButton = refresh
self.model = model
self.proxy = proxy
self.view = view
def _refresh_model(self):
with preserve_expanded_rows(
self.view, column=0, role=self.model.ObjectIdRole
):
with preserve_selection(
self.view, column=0, role=self.model.ObjectIdRole
):
self.model.refresh()
self.assets_refreshed.emit()
def refresh(self):
self._refresh_model()
def get_active_asset(self):
"""Return the asset id the current asset."""
current = self.view.currentIndex()
return current.data(self.model.ItemRole)
def get_active_index(self):
return self.view.currentIndex()
def get_selected_assets(self):
"""Return the assets' ids that are selected."""
selection = self.view.selectionModel()
rows = selection.selectedRows()
return [row.data(self.model.ObjectIdRole) for row in rows]
def select_assets(self, assets, expand=True, key="name"):
"""Select assets by name.
Args:
assets (list): List of asset names
expand (bool): Whether to also expand to the asset in the view
Returns:
None
"""
# TODO: Instead of individual selection optimize for many assets
if not isinstance(assets, (tuple, list)):
assets = [assets]
assert isinstance(
assets, (tuple, list)
), "Assets must be list or tuple"
# convert to list - tuple cant be modified
assets = list(assets)
# Clear selection
selection_model = self.view.selectionModel()
selection_model.clearSelection()
# Select
mode = selection_model.Select | selection_model.Rows
for index in iter_model_rows(
self.proxy, column=0, include_root=False
):
# stop iteration if there are no assets to process
if not assets:
break
value = index.data(self.model.ItemRole).get(key)
if value not in assets:
continue
# Remove processed asset
assets.pop(assets.index(value))
selection_model.select(index, mode)
if expand:
# Expand parent index
self.view.expand(self.proxy.parent(index))
# Set the currently active index
self.view.setCurrentIndex(index)