Merge branch '2.x/develop' into 2.x/feature/photoshop_review

This commit is contained in:
Toke Stuart Jepsen 2020-06-29 09:27:24 +01:00
commit e6c737f2ab
45 changed files with 1180 additions and 401 deletions

View file

@ -6,6 +6,12 @@ from pypeapp import (
execute
)
from pypeapp.lib.mongo import (
decompose_url,
compose_url,
get_default_components
)
from .plugin import (
Extractor,
@ -44,6 +50,9 @@ __all__ = [
"project_overrides_dir_path",
"config",
"execute",
"decompose_url",
"compose_url",
"get_default_components",
# plugin classes
"Extractor",

View file

@ -1,8 +1,9 @@
import os
import sys
from avalon import api, harmony
from avalon import api, io, harmony
from avalon.vendor import Qt
import avalon.tools.sceneinventory
import pyblish.api
from pype import lib
@ -92,6 +93,101 @@ def ensure_scene_settings():
set_scene_settings(valid_settings)
def check_inventory():
if not lib.any_outdated():
return
host = avalon.api.registered_host()
outdated_containers = []
for container in host.ls():
representation = container['representation']
representation_doc = io.find_one(
{
"_id": io.ObjectId(representation),
"type": "representation"
},
projection={"parent": True}
)
if representation_doc and not lib.is_latest(representation_doc):
outdated_containers.append(container)
# Colour nodes.
func = """function func(args){
for( var i =0; i <= args[0].length - 1; ++i)
{
var red_color = new ColorRGBA(255, 0, 0, 255);
node.setColor(args[0][i], red_color);
}
}
func
"""
outdated_nodes = [x["node"] for x in outdated_containers]
harmony.send({"function": func, "args": [outdated_nodes]})
# Warn about outdated containers.
print("Starting new QApplication..")
app = Qt.QtWidgets.QApplication(sys.argv)
message_box = Qt.QtWidgets.QMessageBox()
message_box.setIcon(Qt.QtWidgets.QMessageBox.Warning)
msg = "There are outdated containers in the scene."
message_box.setText(msg)
message_box.exec_()
# Garbage collect QApplication.
del app
def application_launch():
ensure_scene_settings()
check_inventory()
def export_template(backdrops, nodes, filepath):
func = """function func(args)
{
// Add an extra node just so a new group can be created.
var temp_node = node.add("Top", "temp_note", "NOTE", 0, 0, 0);
var template_group = node.createGroup(temp_node, "temp_group");
node.deleteNode( template_group + "/temp_note" );
// This will make Node View to focus on the new group.
selection.clearSelection();
selection.addNodeToSelection(template_group);
Action.perform("onActionEnterGroup()", "Node View");
// Recreate backdrops in group.
for (var i = 0 ; i < args[0].length; i++)
{
Backdrop.addBackdrop(template_group, args[0][i]);
};
// Copy-paste the selected nodes into the new group.
var drag_object = copyPaste.copy(args[1], 1, frame.numberOf, "");
copyPaste.pasteNewNodes(drag_object, template_group, "");
// Select all nodes within group and export as template.
Action.perform( "selectAll()", "Node View" );
copyPaste.createTemplateFromSelection(args[2], args[3]);
// Unfocus the group in Node view, delete all nodes and backdrops
// created during the process.
Action.perform("onActionUpToParent()", "Node View");
node.deleteNode(template_group, true, true);
}
func
"""
harmony.send({
"function": func,
"args": [
backdrops,
nodes,
os.path.basename(filepath),
os.path.dirname(filepath)
]
})
def install():
print("Installing Pype config...")
@ -116,7 +212,7 @@ def install():
"instanceToggled", on_pyblish_instance_toggled
)
api.on("application.launched", ensure_scene_settings)
api.on("application.launched", application_launch)
def on_pyblish_instance_toggled(instance, old_value, new_value):

View file

@ -17,6 +17,7 @@ import six
import avalon.api
from .api import config
log = logging.getLogger(__name__)

View file

@ -16,6 +16,7 @@ import contextlib
from avalon import schema
from avalon.vendor import requests
from avalon.io import extract_port_from_url
# Third-party dependencies
import pymongo
@ -72,8 +73,17 @@ class DbConnector(object):
self.Session.update(self._from_environment())
timeout = int(self.Session["AVALON_TIMEOUT"])
self._mongo_client = pymongo.MongoClient(
self.Session["AVALON_MONGO"], serverSelectionTimeoutMS=timeout)
mongo_url = self.Session["AVALON_MONGO"]
kwargs = {
"host": mongo_url,
"serverSelectionTimeoutMS": timeout
}
port = extract_port_from_url(mongo_url)
if port is not None:
kwargs["port"] = int(port)
self._mongo_client = pymongo.MongoClient(**kwargs)
for retry in range(3):
try:
@ -381,6 +391,10 @@ class DbConnector(object):
if document is None:
break
if document.get("type") == "master_version":
_document = self.find_one({"_id": document["version_id"]})
document["data"] = _document["data"]
parents.append(document)
return parents

View file

@ -4,17 +4,14 @@ import json
import bson
import bson.json_util
from pype.modules.rest_api import RestApi, abort, CallbackResult
from pype.modules.ftrack.lib.custom_db_connector import DbConnector
from pype.modules.ftrack.lib.io_nonsingleton import DbConnector
class AvalonRestApi(RestApi):
dbcon = DbConnector(
os.environ["AVALON_MONGO"],
os.environ["AVALON_DB"]
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.dbcon = DbConnector()
self.dbcon.install()
@RestApi.route("/projects/<project_name>", url_prefix="/avalon", methods="GET")

View file

@ -13,10 +13,12 @@ import time
import uuid
import ftrack_api
import pymongo
from pype.modules.ftrack.lib import credentials
from pype.modules.ftrack.ftrack_server.lib import (
ftrack_events_mongo_settings, check_ftrack_url
check_ftrack_url, get_ftrack_event_mongo_info
)
import socket_thread
@ -30,22 +32,19 @@ class MongoPermissionsError(Exception):
def check_mongo_url(host, port, log_error=False):
"""Checks if mongo server is responding"""
sock = None
try:
sock = socket.create_connection(
(host, port),
timeout=1
)
return True
except socket.error as err:
client = pymongo.MongoClient(host=host, port=port)
# Force connection on a request as the connect=True parameter of
# MongoClient seems to be useless here
client.server_info()
except pymongo.errors.ServerSelectionTimeoutError as err:
if log_error:
print("Can't connect to MongoDB at {}:{} because: {}".format(
host, port, err
))
return False
finally:
if sock is not None:
sock.close()
return True
def validate_credentials(url, user, api):
@ -190,9 +189,10 @@ def main_loop(ftrack_url):
os.environ["FTRACK_EVENT_SUB_ID"] = str(uuid.uuid1())
# Get mongo hostname and port for testing mongo connection
mongo_list = ftrack_events_mongo_settings()
mongo_hostname = mongo_list[0]
mongo_port = mongo_list[1]
mongo_uri, mongo_port, database_name, collection_name = (
get_ftrack_event_mongo_info()
)
# Current file
file_path = os.path.dirname(os.path.realpath(__file__))
@ -270,13 +270,12 @@ def main_loop(ftrack_url):
ftrack_accessible = check_ftrack_url(ftrack_url)
if not mongo_accessible:
mongo_accessible = check_mongo_url(mongo_hostname, mongo_port)
mongo_accessible = check_mongo_url(mongo_uri, mongo_port)
# Run threads only if Ftrack is accessible
if not ftrack_accessible or not mongo_accessible:
if not mongo_accessible and not printed_mongo_error:
mongo_url = mongo_hostname + ":" + mongo_port
print("Can't access Mongo {}".format(mongo_url))
print("Can't access Mongo {}".format(mongo_uri))
if not ftrack_accessible and not printed_ftrack_error:
print("Can't access Ftrack {}".format(ftrack_url))

View file

@ -18,12 +18,13 @@ import ftrack_api.operation
import ftrack_api._centralized_storage_scenario
import ftrack_api.event
from ftrack_api.logging import LazyLogMessage as L
try:
from urllib.parse import urlparse, parse_qs
except ImportError:
from urlparse import urlparse, parse_qs
from pype.api import Logger
from pype.api import (
Logger,
get_default_components,
decompose_url,
compose_url
)
from pype.modules.ftrack.lib.custom_db_connector import DbConnector
@ -32,69 +33,30 @@ TOPIC_STATUS_SERVER = "pype.event.server.status"
TOPIC_STATUS_SERVER_RESULT = "pype.event.server.status.result"
def ftrack_events_mongo_settings():
host = None
port = None
username = None
password = None
collection = None
database = None
auth_db = ""
if os.environ.get('FTRACK_EVENTS_MONGO_URL'):
result = urlparse(os.environ['FTRACK_EVENTS_MONGO_URL'])
host = result.hostname
try:
port = result.port
except ValueError:
raise RuntimeError("invalid port specified")
username = result.username
password = result.password
try:
database = result.path.lstrip("/").split("/")[0]
collection = result.path.lstrip("/").split("/")[1]
except IndexError:
if not database:
raise RuntimeError("missing database name for logging")
try:
auth_db = parse_qs(result.query)['authSource'][0]
except KeyError:
# no auth db provided, mongo will use the one we are connecting to
pass
else:
host = os.environ.get('FTRACK_EVENTS_MONGO_HOST')
port = int(os.environ.get('FTRACK_EVENTS_MONGO_PORT', "0"))
database = os.environ.get('FTRACK_EVENTS_MONGO_DB')
username = os.environ.get('FTRACK_EVENTS_MONGO_USER')
password = os.environ.get('FTRACK_EVENTS_MONGO_PASSWORD')
collection = os.environ.get('FTRACK_EVENTS_MONGO_COL')
auth_db = os.environ.get('FTRACK_EVENTS_MONGO_AUTH_DB', 'avalon')
return host, port, database, username, password, collection, auth_db
def get_ftrack_event_mongo_info():
host, port, database, username, password, collection, auth_db = (
ftrack_events_mongo_settings()
database_name = (
os.environ.get("FTRACK_EVENTS_MONGO_DB") or "pype"
)
collection_name = (
os.environ.get("FTRACK_EVENTS_MONGO_COL") or "ftrack_events"
)
user_pass = ""
if username and password:
user_pass = "{}:{}@".format(username, password)
socket_path = "{}:{}".format(host, port)
mongo_url = os.environ.get("FTRACK_EVENTS_MONGO_URL")
if mongo_url is not None:
components = decompose_url(mongo_url)
_used_ftrack_url = True
else:
components = get_default_components()
_used_ftrack_url = False
dab = ""
if database:
dab = "/{}".format(database)
if not _used_ftrack_url or components["database"] is None:
components["database"] = database_name
auth = ""
if auth_db:
auth = "?authSource={}".format(auth_db)
components.pop("collection", None)
url = "mongodb://{}{}{}{}".format(user_pass, socket_path, dab, auth)
uri = compose_url(**components)
return url, database, collection
return uri, components["port"], database_name, collection_name
def check_ftrack_url(url, log_errors=True):
@ -198,16 +160,17 @@ class StorerEventHub(SocketBaseEventHub):
class ProcessEventHub(SocketBaseEventHub):
hearbeat_msg = b"processor"
url, database, table_name = get_ftrack_event_mongo_info()
uri, port, database, table_name = get_ftrack_event_mongo_info()
is_table_created = False
pypelog = Logger().get_logger("Session Processor")
def __init__(self, *args, **kwargs):
self.dbcon = DbConnector(
mongo_url=self.url,
database_name=self.database,
table_name=self.table_name
self.uri,
self.port,
self.database,
self.table_name
)
super(ProcessEventHub, self).__init__(*args, **kwargs)
@ -269,7 +232,7 @@ class ProcessEventHub(SocketBaseEventHub):
def load_events(self):
"""Load not processed events sorted by stored date"""
ago_date = datetime.datetime.now() - datetime.timedelta(days=3)
result = self.dbcon.delete_many({
self.dbcon.delete_many({
"pype_data.stored": {"$lte": ago_date},
"pype_data.is_processed": True
})

View file

@ -23,12 +23,8 @@ class SessionFactory:
session = None
url, database, table_name = get_ftrack_event_mongo_info()
dbcon = DbConnector(
mongo_url=url,
database_name=database,
table_name=table_name
)
uri, port, database, table_name = get_ftrack_event_mongo_info()
dbcon = DbConnector(uri, port, database, table_name)
# ignore_topics = ["ftrack.meta.connected"]
ignore_topics = []

View file

@ -12,6 +12,7 @@ import atexit
# Third-party dependencies
import pymongo
from pype.api import decompose_url
class NotActiveTable(Exception):
@ -63,13 +64,29 @@ class DbConnector:
log = logging.getLogger(__name__)
timeout = 1000
def __init__(self, mongo_url, database_name, table_name=None):
def __init__(
self, uri, port=None, database_name=None, table_name=None
):
self._mongo_client = None
self._sentry_client = None
self._sentry_logging_handler = None
self._database = None
self._is_installed = False
self._mongo_url = mongo_url
self._uri = uri
components = decompose_url(uri)
if port is None:
port = components.get("port")
if database_name is None:
database_name = components.get("database")
if database_name is None:
raise ValueError(
"Database is not defined for connection. {}".format(uri)
)
self._port = port
self._database_name = database_name
self.active_table = table_name
@ -95,10 +112,16 @@ class DbConnector:
atexit.register(self.uninstall)
logging.basicConfig()
self._mongo_client = pymongo.MongoClient(
self._mongo_url,
serverSelectionTimeoutMS=self.timeout
)
kwargs = {
"host": self._uri,
"serverSelectionTimeoutMS": self.timeout
}
if self._port is not None:
kwargs["port"] = self._port
self._mongo_client = pymongo.MongoClient(**kwargs)
if self._port is None:
self._port = self._mongo_client.PORT
for retry in range(3):
try:
@ -113,11 +136,11 @@ class DbConnector:
else:
raise IOError(
"ERROR: Couldn't connect to %s in "
"less than %.3f ms" % (self._mongo_url, self.timeout)
"less than %.3f ms" % (self._uri, self.timeout)
)
self.log.info("Connected to %s, delay %.3f s" % (
self._mongo_url, time.time() - t1
self._uri, time.time() - t1
))
self._database = self._mongo_client[self._database_name]

View file

@ -16,6 +16,7 @@ import contextlib
from avalon import schema
from avalon.vendor import requests
from avalon.io import extract_port_from_url
# Third-party dependencies
import pymongo
@ -72,8 +73,17 @@ class DbConnector(object):
self.Session.update(self._from_environment())
timeout = int(self.Session["AVALON_TIMEOUT"])
self._mongo_client = pymongo.MongoClient(
self.Session["AVALON_MONGO"], serverSelectionTimeoutMS=timeout)
mongo_url = self.Session["AVALON_MONGO"]
kwargs = {
"host": mongo_url,
"serverSelectionTimeoutMS": timeout
}
port = extract_port_from_url(mongo_url)
if port is not None:
kwargs["port"] = int(port)
self._mongo_client = pymongo.MongoClient(**kwargs)
for retry in range(3):
try:
@ -381,6 +391,10 @@ class DbConnector(object):
if document is None:
break
if document.get("type") == "master_version":
_document = self.find_one({"_id": document["version_id"]})
document["data"] = _document["data"]
parents.append(document)
return parents

View file

@ -1,8 +1,7 @@
import os
import collections
from Qt import QtCore
from pype.api import Logger
from pypeapp.lib.log import _bootstrap_mongo_log
from pypeapp.lib.log import _bootstrap_mongo_log, LOG_COLLECTION_NAME
log = Logger().get_logger("LogModel", "LoggingModule")
@ -41,11 +40,11 @@ class LogModel(QtCore.QAbstractItemModel):
super(LogModel, self).__init__(parent)
self._root_node = Node()
collection = os.environ.get('PYPE_LOG_MONGO_COL')
database = _bootstrap_mongo_log()
self.dbcon = None
if collection in database.list_collection_names():
self.dbcon = database[collection]
# Crash if connection is not possible to skip this module
database = _bootstrap_mongo_log()
if LOG_COLLECTION_NAME in database.list_collection_names():
self.dbcon = database[LOG_COLLECTION_NAME]
def add_log(self, log):
node = Node(log)

View file

@ -1,20 +1,23 @@
from Qt import QtWidgets
from pype.api import Logger
from ..gui.app import LogsWindow
log = Logger().get_logger("LoggingModule", "logging")
class LoggingModule:
def __init__(self, main_parent=None, parent=None):
self.parent = parent
self.log = Logger().get_logger(self.__class__.__name__, "logging")
self.window = LogsWindow()
try:
self.window = LogsWindow()
self.tray_menu = self._tray_menu
except Exception:
self.log.warning(
"Couldn't set Logging GUI due to error.", exc_info=True
)
# Definition of Tray menu
def tray_menu(self, parent_menu):
def _tray_menu(self, parent_menu):
# Menu for Tray App
menu = QtWidgets.QMenu('Logging', parent_menu)
# menu.setProperty('submenu', 'on')

View file

@ -0,0 +1,32 @@
"""Create a camera asset."""
import bpy
from avalon import api
from avalon.blender import Creator, lib
import pype.hosts.blender.plugin
class CreateCamera(Creator):
"""Polygonal static geometry"""
name = "cameraMain"
label = "Camera"
family = "camera"
icon = "video-camera"
def process(self):
asset = self.data["asset"]
subset = self.data["subset"]
name = pype.hosts.blender.plugin.asset_name(asset, subset)
collection = bpy.data.collections.new(name=name)
bpy.context.scene.collection.children.link(collection)
self.data['task'] = api.Session.get('AVALON_TASK')
lib.imprint(collection, self.data)
if (self.options or {}).get("useSelection"):
for obj in lib.get_selection():
collection.objects.link(obj)
return collection

View file

@ -0,0 +1,239 @@
"""Load a camera asset in Blender."""
import logging
from pathlib import Path
from pprint import pformat
from typing import Dict, List, Optional
from avalon import api, blender
import bpy
import pype.hosts.blender.plugin
logger = logging.getLogger("pype").getChild("blender").getChild("load_camera")
class BlendCameraLoader(pype.hosts.blender.plugin.AssetLoader):
"""Load a camera from a .blend file.
Warning:
Loading the same asset more then once is not properly supported at the
moment.
"""
families = ["camera"]
representations = ["blend"]
label = "Link Camera"
icon = "code-fork"
color = "orange"
def _remove(self, objects, lib_container):
for obj in objects:
bpy.data.cameras.remove(obj.data)
bpy.data.collections.remove(bpy.data.collections[lib_container])
def _process(self, libpath, lib_container, container_name, actions):
relative = bpy.context.preferences.filepaths.use_relative_paths
with bpy.data.libraries.load(
libpath, link=True, relative=relative
) as (_, data_to):
data_to.collections = [lib_container]
scene = bpy.context.scene
scene.collection.children.link(bpy.data.collections[lib_container])
camera_container = scene.collection.children[lib_container].make_local()
objects_list = []
for obj in camera_container.objects:
obj = obj.make_local()
obj.data.make_local()
if not obj.get(blender.pipeline.AVALON_PROPERTY):
obj[blender.pipeline.AVALON_PROPERTY] = dict()
avalon_info = obj[blender.pipeline.AVALON_PROPERTY]
avalon_info.update({"container_name": container_name})
if actions[0] is not None:
if obj.animation_data is None:
obj.animation_data_create()
obj.animation_data.action = actions[0]
if actions[1] is not None:
if obj.data.animation_data is None:
obj.data.animation_data_create()
obj.data.animation_data.action = actions[1]
objects_list.append(obj)
camera_container.pop(blender.pipeline.AVALON_PROPERTY)
bpy.ops.object.select_all(action='DESELECT')
return objects_list
def process_asset(
self, context: dict, name: str, namespace: Optional[str] = None,
options: Optional[Dict] = None
) -> Optional[List]:
"""
Arguments:
name: Use pre-defined name
namespace: Use pre-defined namespace
context: Full parenthood of representation to load
options: Additional settings dictionary
"""
libpath = self.fname
asset = context["asset"]["name"]
subset = context["subset"]["name"]
lib_container = pype.hosts.blender.plugin.asset_name(asset, subset)
container_name = pype.hosts.blender.plugin.asset_name(
asset, subset, namespace
)
container = bpy.data.collections.new(lib_container)
container.name = container_name
blender.pipeline.containerise_existing(
container,
name,
namespace,
context,
self.__class__.__name__,
)
container_metadata = container.get(
blender.pipeline.AVALON_PROPERTY)
container_metadata["libpath"] = libpath
container_metadata["lib_container"] = lib_container
objects_list = self._process(
libpath, lib_container, container_name, (None, None))
# Save the list of objects in the metadata container
container_metadata["objects"] = objects_list
nodes = list(container.objects)
nodes.append(container)
self[:] = nodes
return nodes
def update(self, container: Dict, representation: Dict):
"""Update the loaded asset.
This will remove all objects of the current collection, load the new
ones and add them to the collection.
If the objects of the collection are used in another collection they
will not be removed, only unlinked. Normally this should not be the
case though.
Warning:
No nested collections are supported at the moment!
"""
collection = bpy.data.collections.get(
container["objectName"]
)
libpath = Path(api.get_representation_path(representation))
extension = libpath.suffix.lower()
logger.info(
"Container: %s\nRepresentation: %s",
pformat(container, indent=2),
pformat(representation, indent=2),
)
assert collection, (
f"The asset is not loaded: {container['objectName']}"
)
assert not (collection.children), (
"Nested collections are not supported."
)
assert libpath, (
"No existing library file found for {container['objectName']}"
)
assert libpath.is_file(), (
f"The file doesn't exist: {libpath}"
)
assert extension in pype.hosts.blender.plugin.VALID_EXTENSIONS, (
f"Unsupported file: {libpath}"
)
collection_metadata = collection.get(
blender.pipeline.AVALON_PROPERTY)
collection_libpath = collection_metadata["libpath"]
objects = collection_metadata["objects"]
lib_container = collection_metadata["lib_container"]
normalized_collection_libpath = (
str(Path(bpy.path.abspath(collection_libpath)).resolve())
)
normalized_libpath = (
str(Path(bpy.path.abspath(str(libpath))).resolve())
)
logger.debug(
"normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s",
normalized_collection_libpath,
normalized_libpath,
)
if normalized_collection_libpath == normalized_libpath:
logger.info("Library already loaded, not updating...")
return
camera = objects[0]
actions = (camera.animation_data.action, camera.data.animation_data.action)
self._remove(objects, lib_container)
objects_list = self._process(
str(libpath), lib_container, collection.name, actions)
# Save the list of objects in the metadata container
collection_metadata["objects"] = objects_list
collection_metadata["libpath"] = str(libpath)
collection_metadata["representation"] = str(representation["_id"])
bpy.ops.object.select_all(action='DESELECT')
def remove(self, container: Dict) -> bool:
"""Remove an existing container from a Blender scene.
Arguments:
container (avalon-core:container-1.0): Container to remove,
from `host.ls()`.
Returns:
bool: Whether the container was deleted.
Warning:
No nested collections are supported at the moment!
"""
collection = bpy.data.collections.get(
container["objectName"]
)
if not collection:
return False
assert not (collection.children), (
"Nested collections are not supported."
)
collection_metadata = collection.get(
blender.pipeline.AVALON_PROPERTY)
objects = collection_metadata["objects"]
lib_container = collection_metadata["lib_container"]
self._remove(objects, lib_container)
bpy.data.collections.remove(collection)
return True

View file

@ -9,7 +9,7 @@ class ExtractBlend(pype.api.Extractor):
label = "Extract Blend"
hosts = ["blender"]
families = ["animation", "model", "rig", "action", "layout"]
families = ["model", "camera", "rig", "action", "layout", "animation"]
optional = True
def process(self, instance):

View file

@ -48,8 +48,18 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin):
data = asset_entity['data']
context.data["frameStart"] = data.get("frameStart")
context.data["frameEnd"] = data.get("frameEnd")
frame_start = data.get("frameStart")
if frame_start is None:
frame_start = 1
self.log.warning("Missing frame start. Defaulting to 1.")
frame_end = data.get("frameEnd")
if frame_end is None:
frame_end = 2
self.log.warning("Missing frame end. Defaulting to 2.")
context.data["frameStart"] = frame_start
context.data["frameEnd"] = frame_end
handles = data.get("handles") or 0
handle_start = data.get("handleStart")
@ -72,7 +82,7 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin):
context.data["handleStart"] = int(handle_start)
context.data["handleEnd"] = int(handle_end)
frame_start_h = data.get("frameStart") - context.data["handleStart"]
frame_end_h = data.get("frameEnd") + context.data["handleEnd"]
frame_start_h = frame_start - context.data["handleStart"]
frame_end_h = frame_end + context.data["handleEnd"]
context.data["frameStartHandle"] = frame_start_h
context.data["frameEndHandle"] = frame_end_h

View file

@ -14,7 +14,10 @@ import pyblish.api
def _get_script():
"""Get path to the image sequence script."""
from pathlib import Path
try:
from pathlib import Path
except ImportError:
from pathlib2 import Path
try:
from pype.scripts import publish_filesequence
@ -26,6 +29,7 @@ def _get_script():
module_path = module_path[: -len(".pyc")] + ".py"
path = Path(os.path.normpath(module_path)).resolve(strict=True)
assert path is not None, ("Cannot determine path")
return str(path)
@ -840,7 +844,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
# add audio to metadata file if available
audio_file = context.data.get("audioFile")
if os.path.isfile(audio_file):
if audio_file and os.path.isfile(audio_file):
publish_job.update({"audio": audio_file})
# pass Ftrack credentials in case of Muster

View file

@ -19,7 +19,7 @@ class ValidateContainers(pyblish.api.ContextPlugin):
label = "Validate Containers"
order = pyblish.api.ValidatorOrder
hosts = ["maya", "houdini", "nuke"]
hosts = ["maya", "houdini", "nuke", "harmony"]
optional = True
actions = [ShowInventory]

View file

@ -8,12 +8,11 @@ except ImportError:
import errno
class ValidateFFmpegInstalled(pyblish.api.Validator):
class ValidateFFmpegInstalled(pyblish.api.ContextPlugin):
"""Validate availability of ffmpeg tool in PATH"""
order = pyblish.api.ValidatorOrder
label = 'Validate ffmpeg installation'
families = ['review']
optional = True
def is_tool(self, name):
@ -27,7 +26,7 @@ class ValidateFFmpegInstalled(pyblish.api.Validator):
return False
return True
def process(self, instance):
def process(self, context):
ffmpeg_path = pype.lib.get_ffmpeg_tool_path("ffmpeg")
self.log.info("ffmpeg path: `{}`".format(ffmpeg_path))
if self.is_tool(ffmpeg_path) is False:

View file

@ -0,0 +1,42 @@
from avalon import api, harmony
func = """
function getUniqueColumnName( column_prefix )
{
var suffix = 0;
// finds if unique name for a column
var column_name = column_prefix;
while(suffix < 2000)
{
if(!column.type(column_name))
break;
suffix = suffix + 1;
column_name = column_prefix + "_" + suffix;
}
return column_name;
}
function func(args)
{
var uniqueColumnName = getUniqueColumnName(args[0]);
column.add(uniqueColumnName , "SOUND");
column.importSound(uniqueColumnName, 1, args[1]);
}
func
"""
class ImportAudioLoader(api.Loader):
"""Import audio."""
families = ["shot"]
representations = ["wav"]
label = "Import Audio"
def load(self, context, name=None, namespace=None, data=None):
wav_file = api.get_representation_path(context["representation"])
harmony.send(
{"function": func, "args": [context["subset"]["name"], wav_file]}
)

View file

@ -98,33 +98,63 @@ function import_files(args)
transparencyModeAttr.setValue(SGITransparencyMode);
if (extension == "psd")
transparencyModeAttr.setValue(FlatPSDTransparencyMode);
if (extension == "jpg")
transparencyModeAttr.setValue(LayeredPSDTransparencyMode);
node.linkAttr(read, "DRAWING.ELEMENT", uniqueColumnName);
// Create a drawing for each file.
for( var i =0; i <= files.length - 1; ++i)
if (files.length == 1)
{
timing = start_frame + i
// Create a drawing drawing, 'true' indicate that the file exists.
Drawing.create(elemId, timing, true);
Drawing.create(elemId, 1, true);
// Get the actual path, in tmp folder.
var drawingFilePath = Drawing.filename(elemId, timing.toString());
copyFile( files[i], drawingFilePath );
var drawingFilePath = Drawing.filename(elemId, "1");
copyFile(files[0], drawingFilePath);
// Expose the image for the entire frame range.
for( var i =0; i <= frame.numberOf() - 1; ++i)
{
timing = start_frame + i
column.setEntry(uniqueColumnName, 1, timing, "1");
}
} else {
// Create a drawing for each file.
for( var i =0; i <= files.length - 1; ++i)
{
timing = start_frame + i
// Create a drawing drawing, 'true' indicate that the file exists.
Drawing.create(elemId, timing, true);
// Get the actual path, in tmp folder.
var drawingFilePath = Drawing.filename(elemId, timing.toString());
copyFile( files[i], drawingFilePath );
column.setEntry(uniqueColumnName, 1, timing, timing.toString());
column.setEntry(uniqueColumnName, 1, timing, timing.toString());
}
}
var green_color = new ColorRGBA(0, 255, 0, 255);
node.setColor(read, green_color);
return read;
}
import_files
"""
replace_files = """function replace_files(args)
replace_files = """var PNGTransparencyMode = 0; //Premultiplied wih Black
var TGATransparencyMode = 0; //Premultiplied wih Black
var SGITransparencyMode = 0; //Premultiplied wih Black
var LayeredPSDTransparencyMode = 1; //Straight
var FlatPSDTransparencyMode = 2; //Premultiplied wih White
function replace_files(args)
{
var files = args[0];
MessageLog.trace(files);
MessageLog.trace(files.length);
var _node = args[1];
var start_frame = args[2];
var _column = node.linkedColumn(_node, "DRAWING.ELEMENT");
var elemId = column.getElementIdOfDrawing(_column);
// Delete existing drawings.
var timings = column.getDrawingTimings(_column);
@ -133,20 +163,62 @@ replace_files = """function replace_files(args)
column.deleteDrawingAt(_column, parseInt(timings[i]));
}
// Create new drawings.
for( var i =0; i <= files.length - 1; ++i)
{
timing = start_frame + i
// Create a drawing drawing, 'true' indicate that the file exists.
Drawing.create(node.getElementId(_node), timing, true);
// Get the actual path, in tmp folder.
var drawingFilePath = Drawing.filename(
node.getElementId(_node), timing.toString()
);
copyFile( files[i], drawingFilePath );
column.setEntry(_column, 1, timing, timing.toString());
var filename = files[0];
var pos = filename.lastIndexOf(".");
if( pos < 0 )
return null;
var extension = filename.substr(pos+1).toLowerCase();
if(extension == "jpeg")
extension = "jpg";
var transparencyModeAttr = node.getAttr(
_node, frame.current(), "applyMatteToColor"
);
if (extension == "png")
transparencyModeAttr.setValue(PNGTransparencyMode);
if (extension == "tga")
transparencyModeAttr.setValue(TGATransparencyMode);
if (extension == "sgi")
transparencyModeAttr.setValue(SGITransparencyMode);
if (extension == "psd")
transparencyModeAttr.setValue(FlatPSDTransparencyMode);
if (extension == "jpg")
transparencyModeAttr.setValue(LayeredPSDTransparencyMode);
if (files.length == 1)
{
// Create a drawing drawing, 'true' indicate that the file exists.
Drawing.create(elemId, 1, true);
// Get the actual path, in tmp folder.
var drawingFilePath = Drawing.filename(elemId, "1");
copyFile(files[0], drawingFilePath);
MessageLog.trace(files[0]);
MessageLog.trace(drawingFilePath);
// Expose the image for the entire frame range.
for( var i =0; i <= frame.numberOf() - 1; ++i)
{
timing = start_frame + i
column.setEntry(_column, 1, timing, "1");
}
} else {
// Create a drawing for each file.
for( var i =0; i <= files.length - 1; ++i)
{
timing = start_frame + i
// Create a drawing drawing, 'true' indicate that the file exists.
Drawing.create(elemId, timing, true);
// Get the actual path, in tmp folder.
var drawingFilePath = Drawing.filename(elemId, timing.toString());
copyFile( files[i], drawingFilePath );
column.setEntry(_column, 1, timing, timing.toString());
}
}
var green_color = new ColorRGBA(0, 255, 0, 255);
node.setColor(_node, green_color);
}
replace_files
"""
@ -156,8 +228,8 @@ class ImageSequenceLoader(api.Loader):
"""Load images
Stores the imported asset in a container named after the asset.
"""
families = ["shot", "render"]
representations = ["jpeg", "png"]
families = ["shot", "render", "image"]
representations = ["jpeg", "png", "jpg"]
def load(self, context, name=None, namespace=None, data=None):
@ -165,9 +237,18 @@ class ImageSequenceLoader(api.Loader):
os.listdir(os.path.dirname(self.fname))
)
files = []
for f in list(collections[0]):
if collections:
for f in list(collections[0]):
files.append(
os.path.join(
os.path.dirname(self.fname), f
).replace("\\", "/")
)
else:
files.append(
os.path.join(os.path.dirname(self.fname), f).replace("\\", "/")
os.path.join(
os.path.dirname(self.fname), remainder[0]
).replace("\\", "/")
)
read_node = harmony.send(
@ -190,15 +271,23 @@ class ImageSequenceLoader(api.Loader):
def update(self, container, representation):
node = container.pop("node")
path = api.get_representation_path(representation)
collections, remainder = clique.assemble(
os.listdir(
os.path.dirname(api.get_representation_path(representation))
)
os.listdir(os.path.dirname(path))
)
files = []
for f in list(collections[0]):
if collections:
for f in list(collections[0]):
files.append(
os.path.join(
os.path.dirname(path), f
).replace("\\", "/")
)
else:
files.append(
os.path.join(os.path.dirname(self.fname), f).replace("\\", "/")
os.path.join(
os.path.dirname(path), remainder[0]
).replace("\\", "/")
)
harmony.send(

View file

@ -14,18 +14,6 @@ class ImportTemplateLoader(api.Loader):
label = "Import Template"
def load(self, context, name=None, namespace=None, data=None):
# Make backdrops from metadata.
backdrops = context["representation"]["data"].get("backdrops", [])
func = """function func(args)
{
Backdrop.addBackdrop("Top", args[0]);
}
func
"""
for backdrop in backdrops:
harmony.send({"function": func, "args": [backdrop]})
# Import template.
temp_dir = tempfile.mkdtemp()
zip_file = api.get_representation_path(context["representation"])
@ -33,19 +21,6 @@ class ImportTemplateLoader(api.Loader):
with zipfile.ZipFile(zip_file, "r") as zip_ref:
zip_ref.extractall(template_path)
func = """function func(args)
{
var template_path = args[0];
var drag_object = copyPaste.copyFromTemplate(
template_path, 0, 0, copyPaste.getCurrentCreateOptions()
);
copyPaste.pasteNewNodes(
drag_object, "", copyPaste.getCurrentPasteOptions()
);
}
func
"""
func = """function func(args)
{
var template_path = args[0];

View file

@ -28,7 +28,8 @@ class ExtractRender(pyblish.api.InstancePlugin):
scene.currentScene(),
scene.getFrameRate(),
scene.getStartFrame(),
scene.getStopFrame()
scene.getStopFrame(),
sound.getSoundtrackAll().path()
]
}
func
@ -41,6 +42,7 @@ class ExtractRender(pyblish.api.InstancePlugin):
frame_rate = result[3]
frame_start = result[4]
frame_end = result[5]
audio_path = result[6]
# Set output path to temp folder.
path = tempfile.mkdtemp()
@ -111,6 +113,7 @@ class ExtractRender(pyblish.api.InstancePlugin):
mov_path = os.path.join(path, instance.data["name"] + ".mov")
args = [
"ffmpeg", "-y",
"-i", audio_path,
"-i",
os.path.join(path, collection.head + "%04d" + collection.tail),
mov_path

View file

@ -9,5 +9,5 @@ class ExtractSaveScene(pyblish.api.ContextPlugin):
order = pyblish.api.ExtractorOrder - 0.49
hosts = ["harmony"]
def process(self, instance):
def process(self, context):
harmony.save_scene()

View file

@ -2,7 +2,8 @@ import os
import shutil
import pype.api
from avalon import harmony
import avalon.harmony
import pype.hosts.harmony
class ExtractTemplate(pype.api.Extractor):
@ -14,6 +15,7 @@ class ExtractTemplate(pype.api.Extractor):
def process(self, instance):
staging_dir = self.staging_dir(instance)
filepath = os.path.join(staging_dir, "{}.tpl".format(instance.name))
self.log.info("Outputting template to {}".format(staging_dir))
@ -28,7 +30,7 @@ class ExtractTemplate(pype.api.Extractor):
unique_backdrops = [backdrops[x] for x in set(backdrops.keys())]
# Get non-connected nodes within backdrops.
all_nodes = harmony.send(
all_nodes = avalon.harmony.send(
{"function": "node.subNodes", "args": ["Top"]}
)["result"]
for node in [x for x in all_nodes if x not in dependencies]:
@ -43,48 +45,9 @@ class ExtractTemplate(pype.api.Extractor):
dependencies.remove(instance[0])
# Export template.
func = """function func(args)
{
// Add an extra node just so a new group can be created.
var temp_node = node.add("Top", "temp_note", "NOTE", 0, 0, 0);
var template_group = node.createGroup(temp_node, "temp_group");
node.deleteNode( template_group + "/temp_note" );
// This will make Node View to focus on the new group.
selection.clearSelection();
selection.addNodeToSelection(template_group);
Action.perform("onActionEnterGroup()", "Node View");
// Recreate backdrops in group.
for (var i = 0 ; i < args[0].length; i++)
{
Backdrop.addBackdrop(template_group, args[0][i]);
};
// Copy-paste the selected nodes into the new group.
var drag_object = copyPaste.copy(args[1], 1, frame.numberOf, "");
copyPaste.pasteNewNodes(drag_object, template_group, "");
// Select all nodes within group and export as template.
Action.perform( "selectAll()", "Node View" );
copyPaste.createTemplateFromSelection(args[2], args[3]);
// Unfocus the group in Node view, delete all nodes and backdrops
// created during the process.
Action.perform("onActionUpToParent()", "Node View");
node.deleteNode(template_group, true, true);
}
func
"""
harmony.send({
"function": func,
"args": [
unique_backdrops,
dependencies,
"{}.tpl".format(instance.name),
staging_dir
]
})
pype.hosts.harmony.export_template(
unique_backdrops, dependencies, filepath
)
# Prep representation.
os.chdir(staging_dir)
@ -131,7 +94,7 @@ class ExtractTemplate(pype.api.Extractor):
}
func
"""
return harmony.send(
return avalon.harmony.send(
{"function": func, "args": [node]}
)["result"]
@ -150,7 +113,7 @@ class ExtractTemplate(pype.api.Extractor):
func
"""
current_dependencies = harmony.send(
current_dependencies = avalon.harmony.send(
{"function": func, "args": [node]}
)["result"]

View file

@ -2,6 +2,8 @@ import os
import shutil
import pype.api
import avalon.harmony
import pype.hosts.harmony
class ExtractWorkfile(pype.api.Extractor):
@ -12,17 +14,25 @@ class ExtractWorkfile(pype.api.Extractor):
families = ["workfile"]
def process(self, instance):
file_path = instance.context.data["currentFile"]
# Export template.
backdrops = avalon.harmony.send(
{"function": "Backdrop.backdrops", "args": ["Top"]}
)["result"]
nodes = avalon.harmony.send(
{"function": "node.subNodes", "args": ["Top"]}
)["result"]
staging_dir = self.staging_dir(instance)
filepath = os.path.join(staging_dir, "{}.tpl".format(instance.name))
pype.hosts.harmony.export_template(backdrops, nodes, filepath)
# Prep representation.
os.chdir(staging_dir)
shutil.make_archive(
instance.name,
"{}".format(instance.name),
"zip",
os.path.dirname(file_path)
os.path.join(staging_dir, "{}.tpl".format(instance.name))
)
zip_path = os.path.join(staging_dir, instance.name + ".zip")
self.log.info(f"Output zip file: {zip_path}")
representation = {
"name": "tpl",

View file

@ -22,8 +22,7 @@ class IncrementWorkfile(pyblish.api.InstancePlugin):
errored_plugins = get_errored_plugins_from_data(instance.context)
if errored_plugins:
raise RuntimeError(
"Skipping incrementing current file because submission to"
" deadline failed."
"Skipping incrementing current file because publishing failed."
)
scene_dir = version_up(

View file

@ -31,6 +31,12 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin):
def process(self, instance):
expected_settings = pype.hosts.harmony.get_asset_settings()
# Harmony is expected to start at 1.
frame_start = expected_settings["frameStart"]
frame_end = expected_settings["frameEnd"]
expected_settings["frameEnd"] = frame_end - frame_start + 1
expected_settings["frameStart"] = 1
func = """function func()
{
return {

View file

@ -179,7 +179,7 @@ class CreateRender(avalon.maya.Creator):
self.data["framesPerTask"] = 1
self.data["whitelist"] = False
self.data["machineList"] = ""
self.data["useMayaBatch"] = True
self.data["useMayaBatch"] = False
self.data["vrayScene"] = False
# Disable for now as this feature is not working yet
# self.data["assScene"] = False

View file

@ -332,9 +332,9 @@ class CollectMayaRender(pyblish.api.ContextPlugin):
options["extendFrames"] = extend_frames
options["overrideExistingFrame"] = override_frames
maya_render_plugin = "MayaBatch"
if not attributes.get("useMayaBatch", True):
maya_render_plugin = "MayaCmd"
maya_render_plugin = "MayaPype"
if attributes.get("useMayaBatch", True):
maya_render_plugin = "MayaBatch"
options["mayaRenderPlugin"] = maya_render_plugin

View file

@ -41,7 +41,7 @@ payload_skeleton = {
"BatchName": None, # Top-level group name
"Name": None, # Job name, as seen in Monitor
"UserName": None,
"Plugin": "MayaBatch",
"Plugin": "MayaPype",
"Frames": "{start}-{end}x{step}",
"Comment": None,
},
@ -274,7 +274,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin):
step=int(self._instance.data["byFrameStep"]))
payload_skeleton["JobInfo"]["Plugin"] = self._instance.data.get(
"mayaRenderPlugin", "MayaBatch")
"mayaRenderPlugin", "MayaPype")
payload_skeleton["JobInfo"]["BatchName"] = filename
# Job name, as seen in Monitor
@ -311,12 +311,14 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin):
"AVALON_TASK",
"PYPE_USERNAME",
"PYPE_DEV",
"PYPE_LOG_NO_COLORS"
"PYPE_LOG_NO_COLORS",
"PYPE_SETUP_PATH"
]
environment = dict({key: os.environ[key] for key in keys
if key in os.environ}, **api.Session)
environment["PYPE_LOG_NO_COLORS"] = "1"
environment["PYPE_MAYA_VERSION"] = cmds.about(v=True)
payload_skeleton["JobInfo"].update({
"EnvironmentKeyValue%d" % index: "{key}={value}".format(
key=key,
@ -428,7 +430,8 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin):
int(self._instance.data["frameStartHandle"]),
int(self._instance.data["frameEndHandle"])),
"Plugin": "MayaBatch",
"Plugin": self._instance.data.get(
"mayaRenderPlugin", "MayaPype"),
"FramesPerTask": self._instance.data.get("framesPerTask", 1)
}

View file

@ -1,12 +1,78 @@
from avalon import photoshop
from avalon import api, photoshop
from avalon.vendor import Qt
class CreateImage(photoshop.Creator):
class CreateImage(api.Creator):
"""Image folder for publish."""
name = "imageDefault"
label = "Image"
family = "image"
def __init__(self, *args, **kwargs):
super(CreateImage, self).__init__(*args, **kwargs)
def process(self):
groups = []
layers = []
create_group = False
group_constant = photoshop.get_com_objects().constants().psLayerSet
if (self.options or {}).get("useSelection"):
multiple_instances = False
selection = photoshop.get_selected_layers()
if len(selection) > 1:
# Ask user whether to create one image or image per selected
# item.
msg_box = Qt.QtWidgets.QMessageBox()
msg_box.setIcon(Qt.QtWidgets.QMessageBox.Warning)
msg_box.setText(
"Multiple layers selected."
"\nDo you want to make one image per layer?"
)
msg_box.setStandardButtons(
Qt.QtWidgets.QMessageBox.Yes |
Qt.QtWidgets.QMessageBox.No |
Qt.QtWidgets.QMessageBox.Cancel
)
ret = msg_box.exec_()
if ret == Qt.QtWidgets.QMessageBox.Yes:
multiple_instances = True
elif ret == Qt.QtWidgets.QMessageBox.Cancel:
return
if multiple_instances:
for item in selection:
if item.LayerType == group_constant:
groups.append(item)
else:
layers.append(item)
else:
group = photoshop.group_selected_layers()
group.Name = self.name
groups.append(group)
elif len(selection) == 1:
# One selected item. Use group if its a LayerSet (group), else
# create a new group.
if selection[0].LayerType == group_constant:
groups.append(selection[0])
else:
layers.append(selection[0])
elif len(selection) == 0:
# No selection creates an empty group.
create_group = True
else:
create_group = True
if create_group:
group = photoshop.app().ActiveDocument.LayerSets.Add()
group.Name = self.name
groups.append(group)
for layer in layers:
photoshop.select_layers([layer])
group = photoshop.group_selected_layers()
group.Name = layer.Name
groups.append(group)
for group in groups:
self.data.update({"subset": "image" + group.Name})
photoshop.imprint(group, self.data)

View file

@ -0,0 +1,29 @@
import pyblish.api
from pype.action import get_errored_plugins_from_data
from pype.lib import version_up
from avalon import photoshop
class IncrementWorkfile(pyblish.api.InstancePlugin):
"""Increment the current workfile.
Saves the current scene with an increased version number.
"""
label = "Increment Workfile"
order = pyblish.api.IntegratorOrder + 9.0
hosts = ["photoshop"]
families = ["workfile"]
optional = True
def process(self, instance):
errored_plugins = get_errored_plugins_from_data(instance.context)
if errored_plugins:
raise RuntimeError(
"Skipping incrementing current file because publishing failed."
)
scene_path = version_up(instance.context.data["currentFile"])
photoshop.app().ActiveDocument.SaveAs(scene_path)
self.log.info("Incremented workfile to: {}".format(scene_path))

View file

@ -0,0 +1,52 @@
import pyblish.api
import pype.api
from avalon import photoshop
class ValidateNamingRepair(pyblish.api.Action):
"""Repair the instance asset."""
label = "Repair"
icon = "wrench"
on = "failed"
def process(self, context, plugin):
# Get the errored instances
failed = []
for result in context.data["results"]:
if (result["error"] is not None and result["instance"] is not None
and result["instance"] not in failed):
failed.append(result["instance"])
# Apply pyblish.logic to get the instances for the plug-in
instances = pyblish.api.instances_by_plugin(failed, plugin)
for instance in instances:
name = instance.data["name"].replace(" ", "_")
instance[0].Name = name
data = photoshop.read(instance[0])
data["subset"] = "image" + name
photoshop.imprint(instance[0], data)
return True
class ValidateNaming(pyblish.api.InstancePlugin):
"""Validate the instance name.
Spaces in names are not allowed. Will be replace with underscores.
"""
label = "Validate Naming"
hosts = ["photoshop"]
order = pype.api.ValidateContentsOrder
families = ["image"]
actions = [ValidateNamingRepair]
def process(self, instance):
msg = "Name \"{}\" is not allowed.".format(instance.data["name"])
assert " " not in instance.data["name"], msg
msg = "Subset \"{}\" is not allowed.".format(instance.data["subset"])
assert " " not in instance.data["subset"], msg

View file

@ -60,14 +60,8 @@ class CollectShots(pyblish.api.InstancePlugin):
# options to be more flexible.
asset_name = asset_name.split("_")[0]
shot_number = 10
instances = []
for track in tracks:
self.log.info(track)
if "audio" in track.name.lower():
continue
instances = []
for child in track.each_child():
# Transitions are ignored, because Clips have the full frame
@ -75,10 +69,13 @@ class CollectShots(pyblish.api.InstancePlugin):
if isinstance(child, otio.schema.transition.Transition):
continue
# Hardcoded to expect a shot name of "[name].[extension]"
child_name = os.path.splitext(child.name)[0].lower()
name = f"{asset_name}_{child_name}"
frame_start = child.range_in_parent().start_time.value
frame_end = child.range_in_parent().end_time_inclusive().value
name = f"{asset_name}_sh{shot_number:04}"
label = f"{name} (framerange: {frame_start}-{frame_end})"
instances.append(
instance.context.create_instance(**{
@ -96,8 +93,6 @@ class CollectShots(pyblish.api.InstancePlugin):
})
)
shot_number += 10
visual_hierarchy = [asset_entity]
while True:
visual_parent = io.find_one(

View file

@ -26,8 +26,9 @@ class ExtractShot(pype.api.Extractor):
os.path.dirname(editorial_path), basename + ".mov"
)
shot_mov = os.path.join(staging_dir, instance.data["name"] + ".mov")
ffmpeg_path = pype.lib.get_ffmpeg_tool_path("ffmpeg")
args = [
"ffmpeg",
ffmpeg_path,
"-ss", str(instance.data["frameStart"] / fps),
"-i", input_path,
"-t", str(
@ -58,7 +59,7 @@ class ExtractShot(pype.api.Extractor):
shot_jpegs = os.path.join(
staging_dir, instance.data["name"] + ".%04d.jpeg"
)
args = ["ffmpeg", "-i", shot_mov, shot_jpegs]
args = [ffmpeg_path, "-i", shot_mov, shot_jpegs]
self.log.info(f"Processing: {args}")
output = pype.lib._subprocess(args)
self.log.info(output)
@ -79,7 +80,7 @@ class ExtractShot(pype.api.Extractor):
# Generate wav file.
shot_wav = os.path.join(staging_dir, instance.data["name"] + ".wav")
args = ["ffmpeg", "-i", shot_mov, shot_wav]
args = [ffmpeg_path, "-i", shot_mov, shot_wav]
self.log.info(f"Processing: {args}")
output = pype.lib._subprocess(args)
self.log.info(output)

View file

@ -0,0 +1,23 @@
import pyblish.api
import pype.api
class ValidateShots(pyblish.api.ContextPlugin):
"""Validate there is a "mov" next to the editorial file."""
label = "Validate Shots"
hosts = ["standalonepublisher"]
order = pype.api.ValidateContentsOrder
def process(self, context):
shot_names = []
duplicate_names = []
for instance in context:
name = instance.data["name"]
if name in shot_names:
duplicate_names.append(name)
else:
shot_names.append(name)
msg = "There are duplicate shot names:\n{}".format(duplicate_names)
assert not duplicate_names, msg

View file

@ -491,3 +491,24 @@ QToolButton {
#TerminalFilerBtn[type="log_critical"]:checked {color: rgb(255, 79, 117);}
#TerminalFilerBtn[type="log_critical"] {color: rgba(255, 79, 117, 63);}
#SuspendLogsBtn {
background: #444;
border: none;
border-top-right-radius: 7px;
border-bottom-right-radius: 7px;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
font-family: "FontAwesome";
font-size: 11pt;
color: white;
padding: 0px;
}
#SuspendLogsBtn:hover {
background: #333;
}
#SuspendLogsBtn:disabled {
background: #4c4c4c;
}

View file

@ -8,6 +8,7 @@ an active window manager; such as via Travis-CI.
import os
import sys
import traceback
import inspect
from Qt import QtCore
@ -60,11 +61,15 @@ class Controller(QtCore.QObject):
# store OrderGroups - now it is a singleton
order_groups = util.OrderGroups
# When instance is toggled
instance_toggled = QtCore.Signal(object, object, object)
def __init__(self, parent=None):
super(Controller, self).__init__(parent)
self.context = None
self.plugins = {}
self.optional_default = {}
self.instance_toggled.connect(self._on_instance_toggled)
def reset_variables(self):
# Data internal to the GUI itself
@ -81,7 +86,6 @@ class Controller(QtCore.QObject):
# - passing collectors order disables plugin/instance toggle
self.collectors_order = None
self.collect_state = 0
self.collected = False
# - passing validators order disables validate button and gives ability
# to know when to stop on validate button press
@ -415,3 +419,19 @@ class Controller(QtCore.QObject):
for plugin in self.plugins:
del(plugin)
def _on_instance_toggled(self, instance, old_value, new_value):
callbacks = pyblish.api.registered_callbacks().get("instanceToggled")
if not callbacks:
return
for callback in callbacks:
try:
callback(instance, old_value, new_value)
except Exception:
print(
"Callback for `instanceToggled` crashed. {}".format(
os.path.abspath(inspect.getfile(callback))
)
)
traceback.print_exception(*sys.exc_info())

View file

@ -32,7 +32,6 @@ from .awesome import tags as awesome
import Qt
from Qt import QtCore, QtGui
from six import text_type
from six.moves import queue
from .vendor import qtawesome
from .constants import PluginStates, InstanceStates, GroupStates, Roles
@ -49,6 +48,7 @@ TerminalDetailType = QtGui.QStandardItem.UserType + 4
class QAwesomeTextIconFactory:
icons = {}
@classmethod
def icon(cls, icon_name):
if icon_name not in cls.icons:
@ -58,6 +58,7 @@ class QAwesomeTextIconFactory:
class QAwesomeIconFactory:
icons = {}
@classmethod
def icon(cls, icon_name, icon_color):
if icon_name not in cls.icons:
@ -489,12 +490,8 @@ class PluginModel(QtGui.QStandardItemModel):
new_records = result.get("records") or []
if not has_warning:
for record in new_records:
if not hasattr(record, "levelname"):
continue
if str(record.levelname).lower() in [
"warning", "critical", "error"
]:
level_no = record.get("levelno")
if level_no and level_no >= 30:
new_flag_states[PluginStates.HasWarning] = True
break
@ -788,12 +785,8 @@ class InstanceModel(QtGui.QStandardItemModel):
new_records = result.get("records") or []
if not has_warning:
for record in new_records:
if not hasattr(record, "levelname"):
continue
if str(record.levelname).lower() in [
"warning", "critical", "error"
]:
level_no = record.get("levelno")
if level_no and level_no >= 30:
new_flag_states[InstanceStates.HasWarning] = True
break
@ -1009,7 +1002,7 @@ class ArtistProxy(QtCore.QAbstractProxyModel):
return QtCore.QModelIndex()
class TerminalModel(QtGui.QStandardItemModel):
class TerminalDetailItem(QtGui.QStandardItem):
key_label_record_map = (
("instance", "Instance"),
("msg", "Message"),
@ -1022,6 +1015,57 @@ class TerminalModel(QtGui.QStandardItemModel):
("msecs", "Millis")
)
def __init__(self, record_item):
self.record_item = record_item
self.msg = None
msg = record_item.get("msg")
if msg is None:
msg = record_item["label"].split("\n")[0]
super(TerminalDetailItem, self).__init__(msg)
def data(self, role=QtCore.Qt.DisplayRole):
if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole):
if self.msg is None:
self.msg = self.compute_detail_text(self.record_item)
return self.msg
return super(TerminalDetailItem, self).data(role)
def compute_detail_text(self, item_data):
if item_data["type"] == "info":
return item_data["label"]
html_text = ""
for key, title in self.key_label_record_map:
if key not in item_data:
continue
value = item_data[key]
text = (
str(value)
.replace("<", "&#60;")
.replace(">", "&#62;")
.replace('\n', '<br/>')
.replace(' ', '&nbsp;')
)
title_tag = (
'<span style=\" font-size:8pt; font-weight:600;'
# ' background-color:#bbb; color:#333;\" >{}:</span> '
' color:#fff;\" >{}:</span> '
).format(title)
html_text += (
'<tr><td width="100%" align=left>{}</td></tr>'
'<tr><td width="100%">{}</td></tr>'
).format(title_tag, text)
html_text = '<table width="100%" cellspacing="3">{}</table>'.format(
html_text
)
return html_text
class TerminalModel(QtGui.QStandardItemModel):
item_icon_name = {
"info": "fa.info",
"record": "fa.circle",
@ -1053,38 +1097,38 @@ class TerminalModel(QtGui.QStandardItemModel):
self.reset()
def reset(self):
self.items_to_set_widget = queue.Queue()
self.clear()
def prepare_records(self, result):
def prepare_records(self, result, suspend_logs):
prepared_records = []
instance_name = None
instance = result["instance"]
if instance is not None:
instance_name = instance.data["name"]
for record in result.get("records") or []:
if isinstance(record, dict):
record_item = record
else:
record_item = {
"label": text_type(record.msg),
"type": "record",
"levelno": record.levelno,
"threadName": record.threadName,
"name": record.name,
"filename": record.filename,
"pathname": record.pathname,
"lineno": record.lineno,
"msg": text_type(record.msg),
"msecs": record.msecs,
"levelname": record.levelname
}
if not suspend_logs:
for record in result.get("records") or []:
if isinstance(record, dict):
record_item = record
else:
record_item = {
"label": text_type(record.msg),
"type": "record",
"levelno": record.levelno,
"threadName": record.threadName,
"name": record.name,
"filename": record.filename,
"pathname": record.pathname,
"lineno": record.lineno,
"msg": text_type(record.msg),
"msecs": record.msecs,
"levelname": record.levelname
}
if instance_name is not None:
record_item["instance"] = instance_name
if instance_name is not None:
record_item["instance"] = instance_name
prepared_records.append(record_item)
prepared_records.append(record_item)
error = result.get("error")
if error:
@ -1140,49 +1184,14 @@ class TerminalModel(QtGui.QStandardItemModel):
self.appendRow(top_item)
detail_text = self.prepare_detail_text(record_item)
detail_item = QtGui.QStandardItem(detail_text)
detail_item = TerminalDetailItem(record_item)
detail_item.setData(TerminalDetailType, Roles.TypeRole)
top_item.appendRow(detail_item)
self.items_to_set_widget.put(detail_item)
def update_with_result(self, result):
for record in result["records"]:
self.append(record)
def prepare_detail_text(self, item_data):
if item_data["type"] == "info":
return item_data["label"]
html_text = ""
for key, title in self.key_label_record_map:
if key not in item_data:
continue
value = item_data[key]
text = (
str(value)
.replace("<", "&#60;")
.replace(">", "&#62;")
.replace('\n', '<br/>')
.replace(' ', '&nbsp;')
)
title_tag = (
'<span style=\" font-size:8pt; font-weight:600;'
# ' background-color:#bbb; color:#333;\" >{}:</span> '
' color:#fff;\" >{}:</span> '
).format(title)
html_text += (
'<tr><td width="100%" align=left>{}</td></tr>'
'<tr><td width="100%">{}</td></tr>'
).format(title_tag, text)
html_text = '<table width="100%" cellspacing="3">{}</table>'.format(
html_text
)
return html_text
class TerminalProxy(QtCore.QSortFilterProxyModel):
filter_buttons_checks = {

View file

@ -1,6 +1,14 @@
from Qt import QtCore, QtWidgets
from . import model
from .constants import Roles
# Imported when used
widgets = None
def _import_widgets():
global widgets
if widgets is None:
from . import widgets
class ArtistView(QtWidgets.QListView):
@ -151,6 +159,8 @@ class TerminalView(QtWidgets.QTreeView):
self.clicked.connect(self.item_expand)
_import_widgets()
def event(self, event):
if not event.type() == QtCore.QEvent.KeyPress:
return super(TerminalView, self).event(event)
@ -190,6 +200,23 @@ class TerminalView(QtWidgets.QTreeView):
self.updateGeometry()
self.scrollToBottom()
def expand(self, index):
"""Wrapper to set widget for expanded index."""
model = index.model()
row_count = model.rowCount(index)
is_new = False
for child_idx in range(row_count):
child_index = model.index(child_idx, index.column(), index)
widget = self.indexWidget(child_index)
if widget is None:
is_new = True
msg = child_index.data(QtCore.Qt.DisplayRole)
widget = widgets.TerminalDetail(msg)
self.setIndexWidget(child_index, widget)
super(TerminalView, self).expand(index)
if is_new:
self.updateGeometries()
def resizeEvent(self, event):
super(self.__class__, self).resizeEvent(event)
self.model().layoutChanged.emit()

View file

@ -321,11 +321,6 @@ class PerspectiveWidget(QtWidgets.QWidget):
data = {"records": records}
self.terminal_model.reset()
self.terminal_model.update_with_result(data)
while not self.terminal_model.items_to_set_widget.empty():
item = self.terminal_model.items_to_set_widget.get()
widget = TerminalDetail(item.data(QtCore.Qt.DisplayRole))
index = self.terminal_proxy.mapFromSource(item.index())
self.terminal_view.setIndexWidget(index, widget)
self.records.button_toggle_text.setText(
"{} ({})".format(self.l_rec, len_records)

View file

@ -54,6 +54,7 @@ class Window(QtWidgets.QDialog):
def __init__(self, controller, parent=None):
super(Window, self).__init__(parent=parent)
self._suspend_logs = False
# Use plastique style for specific ocations
# TODO set style name via environment variable
low_keys = {
@ -95,6 +96,18 @@ class Window(QtWidgets.QDialog):
header_tab_terminal = QtWidgets.QRadioButton(header_tab_widget)
header_spacer = QtWidgets.QWidget(header_tab_widget)
button_suspend_logs_widget = QtWidgets.QWidget()
button_suspend_logs_widget_layout = QtWidgets.QHBoxLayout(
button_suspend_logs_widget
)
button_suspend_logs_widget_layout.setContentsMargins(0, 10, 0, 10)
button_suspend_logs = QtWidgets.QPushButton(header_widget)
button_suspend_logs.setFixedWidth(7)
button_suspend_logs.setSizePolicy(
QtWidgets.QSizePolicy.Preferred,
QtWidgets.QSizePolicy.Expanding
)
button_suspend_logs_widget_layout.addWidget(button_suspend_logs)
header_aditional_btns = QtWidgets.QWidget(header_tab_widget)
aditional_btns_layout = QtWidgets.QHBoxLayout(header_aditional_btns)
@ -109,9 +122,11 @@ class Window(QtWidgets.QDialog):
layout_tab.addWidget(header_tab_artist, 0)
layout_tab.addWidget(header_tab_overview, 0)
layout_tab.addWidget(header_tab_terminal, 0)
layout_tab.addWidget(button_suspend_logs_widget, 0)
# Compress items to the left
layout_tab.addWidget(header_spacer, 1)
layout_tab.addWidget(header_aditional_btns, 1)
layout_tab.addWidget(header_aditional_btns, 0)
layout = QtWidgets.QHBoxLayout(header_widget)
layout.setContentsMargins(0, 0, 0, 0)
@ -226,6 +241,10 @@ class Window(QtWidgets.QDialog):
footer_info = QtWidgets.QLabel(footer_widget)
footer_spacer = QtWidgets.QWidget(footer_widget)
footer_button_stop = QtWidgets.QPushButton(
awesome["stop"], footer_widget
)
footer_button_reset = QtWidgets.QPushButton(
awesome["refresh"], footer_widget
)
@ -235,14 +254,12 @@ class Window(QtWidgets.QDialog):
footer_button_play = QtWidgets.QPushButton(
awesome["play"], footer_widget
)
footer_button_stop = QtWidgets.QPushButton(
awesome["stop"], footer_widget
)
layout = QtWidgets.QHBoxLayout()
layout.setContentsMargins(5, 5, 5, 5)
layout.addWidget(footer_info, 0)
layout.addWidget(footer_spacer, 1)
layout.addWidget(footer_button_stop, 0)
layout.addWidget(footer_button_reset, 0)
layout.addWidget(footer_button_validate, 0)
@ -342,10 +359,11 @@ class Window(QtWidgets.QDialog):
"TerminalView": terminal_view,
# Buttons
"Play": footer_button_play,
"Validate": footer_button_validate,
"Reset": footer_button_reset,
"SuspendLogsBtn": button_suspend_logs,
"Stop": footer_button_stop,
"Reset": footer_button_reset,
"Validate": footer_button_validate,
"Play": footer_button_play,
# Misc
"HeaderSpacer": header_spacer,
@ -370,10 +388,11 @@ class Window(QtWidgets.QDialog):
overview_page,
terminal_page,
footer_widget,
footer_button_play,
footer_button_validate,
button_suspend_logs,
footer_button_stop,
footer_button_reset,
footer_button_validate,
footer_button_play,
footer_spacer,
closing_placeholder
):
@ -415,10 +434,11 @@ class Window(QtWidgets.QDialog):
QtCore.Qt.DirectConnection
)
artist_view.toggled.connect(self.on_item_toggled)
overview_instance_view.toggled.connect(self.on_item_toggled)
overview_plugin_view.toggled.connect(self.on_item_toggled)
artist_view.toggled.connect(self.on_instance_toggle)
overview_instance_view.toggled.connect(self.on_instance_toggle)
overview_plugin_view.toggled.connect(self.on_plugin_toggle)
button_suspend_logs.clicked.connect(self.on_suspend_clicked)
footer_button_stop.clicked.connect(self.on_stop_clicked)
footer_button_reset.clicked.connect(self.on_reset_clicked)
footer_button_validate.clicked.connect(self.on_validate_clicked)
@ -442,10 +462,11 @@ class Window(QtWidgets.QDialog):
self.terminal_filters_widget = terminal_filters_widget
self.footer_widget = footer_widget
self.button_suspend_logs = button_suspend_logs
self.footer_button_stop = footer_button_stop
self.footer_button_reset = footer_button_reset
self.footer_button_validate = footer_button_validate
self.footer_button_play = footer_button_play
self.footer_button_stop = footer_button_stop
self.overview_instance_view = overview_instance_view
self.overview_plugin_view = overview_plugin_view
@ -537,7 +558,29 @@ class Window(QtWidgets.QDialog):
):
instance_item.setData(enable_value, Roles.IsEnabledRole)
def on_item_toggled(self, index, state=None):
def on_instance_toggle(self, index, state=None):
"""An item is requesting to be toggled"""
if not index.data(Roles.IsOptionalRole):
return self.info("This item is mandatory")
if self.controller.collect_state != 1:
return self.info("Cannot toggle")
current_state = index.data(QtCore.Qt.CheckStateRole)
if state is None:
state = not current_state
instance_id = index.data(Roles.ObjectIdRole)
instance_item = self.instance_model.instance_items[instance_id]
instance_item.setData(state, QtCore.Qt.CheckStateRole)
self.controller.instance_toggled.emit(
instance_item.instance, current_state, state
)
self.update_compatibility()
def on_plugin_toggle(self, index, state=None):
"""An item is requesting to be toggled"""
if not index.data(Roles.IsOptionalRole):
return self.info("This item is mandatory")
@ -548,7 +591,10 @@ class Window(QtWidgets.QDialog):
if state is None:
state = not index.data(QtCore.Qt.CheckStateRole)
index.model().setData(index, state, QtCore.Qt.CheckStateRole)
plugin_id = index.data(Roles.ObjectIdRole)
plugin_item = self.plugin_model.plugin_items[plugin_id]
plugin_item.setData(state, QtCore.Qt.CheckStateRole)
self.update_compatibility()
def on_tab_changed(self, target):
@ -587,6 +633,13 @@ class Window(QtWidgets.QDialog):
self.footer_button_play.setEnabled(False)
self.footer_button_stop.setEnabled(False)
def on_suspend_clicked(self):
self._suspend_logs = not self._suspend_logs
if self.state["current_page"] == "terminal":
self.on_tab_changed("overview")
self.tabs["terminal"].setVisible(not self._suspend_logs)
def on_comment_entered(self):
"""The user has typed a comment."""
self.controller.context.data["comment"] = self.comment_box.text()
@ -701,14 +754,14 @@ class Window(QtWidgets.QDialog):
self.on_tab_changed(self.state["current_page"])
self.update_compatibility()
self.footer_button_validate.setEnabled(True)
self.footer_button_reset.setEnabled(True)
self.footer_button_stop.setEnabled(False)
self.footer_button_play.setEnabled(True)
self.footer_button_play.setFocus()
self.button_suspend_logs.setEnabled(False)
self.footer_button_validate.setEnabled(False)
self.footer_button_reset.setEnabled(False)
self.footer_button_stop.setEnabled(True)
self.footer_button_play.setEnabled(False)
def on_passed_group(self, order):
for group_item in self.instance_model.group_items.values():
if self.overview_instance_view.isExpanded(group_item.index()):
continue
@ -740,16 +793,28 @@ class Window(QtWidgets.QDialog):
def on_was_stopped(self):
errored = self.controller.errored
self.footer_button_play.setEnabled(not errored)
self.footer_button_validate.setEnabled(
not errored and not self.controller.validated
)
if self.controller.collect_state == 0:
self.footer_button_play.setEnabled(False)
self.footer_button_validate.setEnabled(False)
else:
self.footer_button_play.setEnabled(not errored)
self.footer_button_validate.setEnabled(
not errored and not self.controller.validated
)
self.footer_button_play.setFocus()
self.footer_button_reset.setEnabled(True)
self.footer_button_stop.setEnabled(False)
if errored:
self.footer_widget.setProperty("success", 0)
self.footer_widget.style().polish(self.footer_widget)
suspend_log_bool = (
self.controller.collect_state == 1
and not self.controller.stopped
)
self.button_suspend_logs.setEnabled(suspend_log_bool)
def on_was_skipped(self, plugin):
plugin_item = self.plugin_model.plugin_items[plugin.id]
plugin_item.setData(
@ -809,17 +874,15 @@ class Window(QtWidgets.QDialog):
if self.tabs["artist"].isChecked():
self.tabs["overview"].toggle()
result["records"] = self.terminal_model.prepare_records(result)
result["records"] = self.terminal_model.prepare_records(
result,
self._suspend_logs
)
plugin_item = self.plugin_model.update_with_result(result)
instance_item = self.instance_model.update_with_result(result)
self.terminal_model.update_with_result(result)
while not self.terminal_model.items_to_set_widget.empty():
item = self.terminal_model.items_to_set_widget.get()
widget = widgets.TerminalDetail(item.data(QtCore.Qt.DisplayRole))
index = self.terminal_proxy.mapFromSource(item.index())
self.terminal_view.setIndexWidget(index, widget)
self.update_compatibility()
@ -872,16 +935,19 @@ class Window(QtWidgets.QDialog):
self.footer_button_validate.setEnabled(False)
self.footer_button_play.setEnabled(False)
self.button_suspend_logs.setEnabled(False)
util.defer(5, self.controller.validate)
def publish(self):
self.info(self.tr("Preparing publish.."))
self.footer_button_stop.setEnabled(True)
self.footer_button_reset.setEnabled(False)
self.footer_button_validate.setEnabled(False)
self.footer_button_play.setEnabled(False)
self.button_suspend_logs.setEnabled(False)
util.defer(5, self.controller.publish)
def act(self, plugin_item, action):
@ -913,30 +979,24 @@ class Window(QtWidgets.QDialog):
plugin_item = self.plugin_model.plugin_items[result["plugin"].id]
action_state = plugin_item.data(Roles.PluginActionProgressRole)
action_state |= PluginActionStates.HasFinished
result["records"] = self.terminal_model.prepare_records(result)
result["records"] = self.terminal_model.prepare_records(
result,
self._suspend_logs
)
error = result.get("error")
if error:
records = result.get("records") or []
if result.get("error"):
action_state |= PluginActionStates.HasFailed
fname, line_no, func, exc = error.traceback
records.append({
"label": str(error),
"type": "error",
"filename": str(fname),
"lineno": str(line_no),
"func": str(func),
"traceback": error.formatted_traceback
})
result["records"] = records
plugin_item.setData(action_state, Roles.PluginActionProgressRole)
self.plugin_model.update_with_result(result)
self.instance_model.update_with_result(result)
self.terminal_model.update_with_result(result)
plugin_item = self.plugin_model.update_with_result(result)
instance_item = self.instance_model.update_with_result(result)
if self.perspective_widget.isVisible():
self.perspective_widget.update_context(
plugin_item, instance_item
)
def closeEvent(self, event):
"""Perform post-flight checks before closing

View file

@ -1 +1 @@
__version__ = "2.9.1"
__version__ = "2.10.0"

View file

@ -56,13 +56,6 @@
"pattern": "^\\w*$",
"example": "maya2016"
},
"AVALON_MONGO": {
"description": "Address to the asset database",
"type": "string",
"pattern": "^mongodb://[\\w/@:.]*$",
"example": "mongodb://localhost:27017",
"default": "mongodb://localhost:27017"
},
"AVALON_DB": {
"description": "Name of database",
"type": "string",