diff --git a/avalon/api.py b/avalon/api.py index 33e4a3bed..d73a478eb 100644 --- a/avalon/api.py +++ b/avalon/api.py @@ -42,6 +42,8 @@ get_representation_context, update_current_task, + get_session_template_data, + get_publish_path, get_representation_path, loaders_from_representation, @@ -96,6 +98,8 @@ "get_representation_context", "update_current_task", + "get_session_template_data", + "get_publish_path", "get_representation_path", "loaders_from_representation", diff --git a/avalon/inventory.py b/avalon/inventory.py index 4f54054e0..f667566e7 100644 --- a/avalon/inventory.py +++ b/avalon/inventory.py @@ -107,7 +107,7 @@ def create_project(name): }).inserted_id -def create_asset(name, silo, data, parent): +def create_asset(name, silo, data, parent, episode, sequence, asset_type): assert isinstance(parent, io.ObjectId) if io.find_one({"type": "asset", "name": name}): raise RuntimeError("%s already exists" % name) @@ -116,6 +116,9 @@ def create_asset(name, silo, data, parent): "schema": "avalon-core:asset-2.0", "name": name, "silo": silo, + "episode": episode, + "sequence": sequence, + "asset_type": asset_type, "parent": parent, "type": "asset", "data": data @@ -313,7 +316,10 @@ def _save_inventory_1_0(project_name, data): name=data.pop("name"), silo=data.pop("silo"), data=data, - parent=document["_id"] + parent=document["_id"], + episode=data.pop("episode"), + sequence=data.pop("sequence"), + asset_type=data.pop("asset_type") ) else: diff --git a/avalon/io.py b/avalon/io.py index b8a17a74e..0f634c047 100644 --- a/avalon/io.py +++ b/avalon/io.py @@ -120,6 +120,15 @@ def _from_environment(): # Name of current Project ("AVALON_PROJECT", None), + # Name of current Episode + ("AVALON_EPISODE", None), + + # Name of current Sequence + ("AVALON_SEQUENCE", None), + + # Name of current Asset type + ("AVALON_ASSET_TYPE", None), + # Name of current Asset ("AVALON_ASSET", None), diff --git a/avalon/maya/pipeline.py b/avalon/maya/pipeline.py index 74bc11c30..9596fbd1a 100644 --- a/avalon/maya/pipeline.py +++ b/avalon/maya/pipeline.py @@ -137,6 +137,9 @@ def deferred(): enable=False ) + cmds.menuItem("Switch Task", + command=lambda *args: workfiles.show_switch_task(parent=self._parent)) + cmds.setParent("..", menu=True) cmds.menuItem(divider=True) diff --git a/avalon/nuke/pipeline.py b/avalon/nuke/pipeline.py index 18d9b56f8..206959383 100644 --- a/avalon/nuke/pipeline.py +++ b/avalon/nuke/pipeline.py @@ -414,6 +414,9 @@ def _install_menu(): # Create menu menubar = nuke.menu("Nuke") menu = menubar.addMenu(api.Session["AVALON_LABEL"]) + menu.addCommand("Switch Task", + lambda: workfiles.show_switch_task(parent=get_main_window()) + ) _add_contextmanager_menu(menu) diff --git a/avalon/nuke/workio.py b/avalon/nuke/workio.py index b36f91a37..067e835a6 100644 --- a/avalon/nuke/workio.py +++ b/avalon/nuke/workio.py @@ -1,5 +1,6 @@ """Host API required Work Files tool""" import os +import sys import nuke @@ -75,9 +76,31 @@ def open_file(filepath): nuke.connectViewer(0, viewer) nuke.callbacks.onScriptLoad() + # Set colorManagement + _set_color_management() + return True +def _set_color_management(): + if os.getenv("OCIO"): + # log.info("Setup project color management to OCIO") + # nuke.knob("root.colorManagement", "OCIO") + + # region === Switch colorSpace === + project_name = os.environ.get("AVALON_PROJECT") + show_config_root = os.environ.get("MS_SHOW_CONFIG_ROOT") + if project_name and show_config_root: + project_root = r'{}/ms_show_config/show/s_{}'.format( + show_config_root, project_name) + if os.path.isfile(r'{}/switch_lut.py'.format(project_root)): + sys.path.insert(0, project_root) + import switch_lut + if hasattr(switch_lut, 'switch_viewPort_lut'): + switch_lut.switch_viewPort_lut() + # endregion + + def clear_root_user_knobs(tabs=[]): root_knobs = nuke.Root().allKnobs() diff --git a/avalon/pipeline.py b/avalon/pipeline.py index 315256ccb..c83b7191e 100644 --- a/avalon/pipeline.py +++ b/avalon/pipeline.py @@ -1069,11 +1069,14 @@ def compute_session_changes(session, task=None, asset=None, app=None): # Detect any changes compared session mapping = { "AVALON_ASSET": asset, + "AVALON_EPISODE": asset_document.get("episode", ""), + "AVALON_SEQUENCE": asset_document.get("sequence", ""), + "AVALON_ASSET_TYPE": asset_document.get("asset_type", ""), "AVALON_TASK": task, "AVALON_APP": app, } changes = {key: value for key, value in mapping.items() - if value and value != session.get(key)} + if value is not None and value != session.get(key)} if not changes: return changes @@ -1155,7 +1158,14 @@ def _format_work_template(template, session=None): if session is None: session = Session - return template.format(**{ + if session.get("AVALON_SILO") == "Assets": + template = template.replace("{category}", "{asset_type}") + elif session.get("AVALON_SILO") == "Shots": + template = template.replace("{category}", "{episode}/{sequence}") + else: + template = template.replace("{category}", "") + + path = template.format(**{ "root": registered_root(), "project": session["AVALON_PROJECT"], "asset": session["AVALON_ASSET"], @@ -1164,9 +1174,13 @@ def _format_work_template(template, session=None): # Optional "silo": session.get("AVALON_SILO"), + "episode": session.get("AVALON_EPISODE", ""), + "sequence": session.get("AVALON_SEQUENCE", ""), + "asset_type": session.get("AVALON_ASSET_TYPE", ""), "user": session.get("AVALON_USER", getpass.getuser()), "hierarchy": session.get("AVALON_HIERARCHY"), }) + return os.path.normpath(path).replace('\\', '/') def _make_backwards_compatible_loader(Loader): @@ -1359,6 +1373,164 @@ def switch(container, representation): return new_loader.switch_form_loader(container, new_representation, loader) +def get_session_template_data(): + project = io.find_one({"name": Session["AVALON_PROJECT"], "type": "project"}) + project_root = project["data"].get("root") + root = project_root or registered_root() + silo = Session.get("AVALON_SILO", "") + + category = "" + if silo == "Assets": + category = Session.get("AVALON_ASSET_TYPE", "") + elif silo == "Shots": + category = "%s/%s" % (Session.get("AVALON_EPISODE", ""), Session.get("AVALON_SEQUENCE", "")) + + data = { + "root": root, + "project": Session["AVALON_PROJECT"], + "silo": silo, + "episode": Session.get("AVALON_EPISODE", ""), + "sequence": Session.get("AVALON_SEQUENCE", ""), + "asset_type": Session.get("AVALON_ASSET_TYPE", ""), + "category": category, + "asset": Session.get("AVALON_ASSET", ""), + "task": Session.get("AVALON_TASK", ""), + "subset": "", + "version": "", + "representation": "", + "user": Session.get("AVALON_USER", getpass.getuser()), + "app": Session.get("AVALON_APP", ""), + "hierarchy": Session.get("AVALON_HIERARCHY", ""), + } + return data + + +def get_publish_path(representation_name, version, subset, asset, + root=None, project=None): + project = project or io.find_one({"name": Session["AVALON_PROJECT"], "type": "project"}) + try: + template = project["config"]["template"]["publish"] + except KeyError: + log.debug( + "No template in project %s, " + "likely a bug" % project["name"] + ) + return None + + project_root = project["data"].get("root") + root = root or project_root or registered_root() + + if asset.get("silo") == "Assets": + template = template.replace("{category}", "{asset_type}") + elif asset.get("silo") == "Shots": + template = template.replace("{category}", "{episode}/{sequence}") + else: + template = template.replace("{category}", "") + + data = { + "root": root, + "project": project.get("name"), + "asset": asset.get("name"), + "silo": asset.get("silo"), + "episode": asset.get("episode", ""), + "sequence": asset.get("sequence", ""), + "asset_type": asset.get("asset_type", ""), + "subset": subset.get("name"), + "version": version.get("name"), + "representation": representation_name, + "user": Session.get("AVALON_USER", getpass.getuser()), + "app": Session.get("AVALON_APP", ""), + "task": version.get("data", {}).get("task") or Session.get("AVALON_TASK") + } + + try: + path = template.format(**data) + except KeyError as e: + log.debug("Template references unavailable data: %s" % e) + return None + + return os.path.normpath(path) + + +def get_session_template_data(): + project = io.find_one({"name": Session["AVALON_PROJECT"], "type": "project"}) + project_root = project["data"].get("root") + root = project_root or registered_root() + silo = Session.get("AVALON_SILO", "") + + category = "" + if silo == "Assets": + category = Session.get("AVALON_ASSET_TYPE", "") + elif silo == "Shots": + category = "%s/%s" % (Session.get("AVALON_EPISODE", ""), Session.get("AVALON_SEQUENCE", "")) + + data = { + "root": root, + "project": Session["AVALON_PROJECT"], + "silo": silo, + "episode": Session.get("AVALON_EPISODE", ""), + "sequence": Session.get("AVALON_SEQUENCE", ""), + "asset_type": Session.get("AVALON_ASSET_TYPE", ""), + "category": category, + "asset": Session.get("AVALON_ASSET", ""), + "task": Session.get("AVALON_TASK", ""), + "subset": "", + "version": "", + "representation": "", + "user": Session.get("AVALON_USER", getpass.getuser()), + "app": Session.get("AVALON_APP", ""), + "hierarchy": Session.get("AVALON_HIERARCHY", ""), + } + return data + + +def get_publish_path(representation_name, version, subset, asset, + root=None, project=None): + project = project or io.find_one({"name": Session["AVALON_PROJECT"], "type": "project"}) + try: + template = project["config"]["template"]["publish"] + except KeyError: + log.debug( + "No template in project %s, " + "likely a bug" % project["name"] + ) + return None + + project_root = project["data"].get("root") + root = root or project_root or registered_root() + + if asset.get("silo") == "Assets": + template = template.replace("{category}", "{asset_type}") + elif asset.get("silo") == "Shots": + template = template.replace("{category}", "{episode}/{sequence}") + else: + template = template.replace("{category}", "") + + data = { + "root": root, + "project": project.get("name"), + "asset": asset.get("name"), + "silo": asset.get("silo"), + "episode": asset.get("episode", ""), + "sequence": asset.get("sequence", ""), + "asset_type": asset.get("asset_type", ""), + "subset": subset.get("name"), + "version": version.get("name"), + "representation": representation_name, + "user": Session.get("AVALON_USER", getpass.getuser()), + "app": Session.get("AVALON_APP", ""), + "task": version.get("data", {}).get("task") or Session.get("AVALON_TASK") + } + + try: + path = template.format(**data) + except KeyError as e: + log.debug("Template references unavailable data: %s" % e) + return None + + return os.path.normpath(path) + + def get_representation_path(representation): """Get filename from representation document @@ -1406,37 +1578,9 @@ def path_from_config(): ) return None - try: - template = project["config"]["template"]["publish"] - except KeyError: - log.debug( - "No template in project %s, " - "likely a bug" % project["name"] - ) - return None - - # Cannot fail, required members only - data = { - "root": registered_root(), - "project": project["name"], - "asset": asset["name"], - "silo": asset.get("silo"), - "subset": subset["name"], - "version": version_["name"], - "representation": representation["name"], - "user": Session.get("AVALON_USER", getpass.getuser()), - "app": Session.get("AVALON_APP", ""), - "task": Session.get("AVALON_TASK", "") - } + repr_root = representation["data"].get("reprRoot") - try: - path = template.format(**data) - except KeyError as e: - log.debug("Template references unavailable data: %s" % e) - return None - - if os.path.exists(path): - return os.path.normpath(path) + return get_publish_path(representation["name"], version_, subset, asset, root=repr_root, project=project) def path_from_data(): if "path" not in representation["data"]: diff --git a/avalon/schema/asset-2.0.json b/avalon/schema/asset-2.0.json index 8ea33feec..38d69b193 100644 --- a/avalon/schema/asset-2.0.json +++ b/avalon/schema/asset-2.0.json @@ -33,6 +33,18 @@ "description": "Unique identifier to parent document", "example": "592c33475f8c1b064c4d1696" }, + "episode": { + "description": "Unique identifier to episode document", + "example": "592c33475f8c1b064c4d1696" + }, + "sequence": { + "description": "Unique identifier to sequence document", + "example": "592c33475f8c1b064c4d1696" + }, + "asset_type": { + "description": "Unique identifier to asset_type document", + "example": "592c33475f8c1b064c4d1696" + }, "name": { "description": "Name of asset", "type": "string", diff --git a/avalon/schema/asset-3.0.json b/avalon/schema/asset-3.0.json index c021601ea..88eff32c2 100644 --- a/avalon/schema/asset-3.0.json +++ b/avalon/schema/asset-3.0.json @@ -32,6 +32,18 @@ "description": "Unique identifier to parent document", "example": "592c33475f8c1b064c4d1696" }, + "episode": { + "description": "Unique identifier to episode document", + "example": "592c33475f8c1b064c4d1696" + }, + "sequence": { + "description": "Unique identifier to sequence document", + "example": "592c33475f8c1b064c4d1696" + }, + "asset_type": { + "description": "Unique identifier to asset_type document", + "example": "592c33475f8c1b064c4d1696" + }, "name": { "description": "Name of asset", "type": "string", diff --git a/avalon/schema/asset_type-1.0.json b/avalon/schema/asset_type-1.0.json new file mode 100644 index 000000000..35491b89a --- /dev/null +++ b/avalon/schema/asset_type-1.0.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "title": "avalon-core:asset_type-1.0", + "description": "A unit of data", + + "type": "object", + + "additionalProperties": true, + + "required": [ + "schema", + "type", + "name", + "silo", + "data" + ], + + "properties": { + "schema": { + "description": "Schema identifier for payload", + "type": "string", + "enum": ["avalon-core:asset_type-1.0"], + "example": "avalon-core:asset_type-1.0" + }, + "type": { + "description": "The type of document", + "type": "string", + "enum": ["asset_type"], + "example": "asset_type" + }, + "parent": { + "description": "Unique identifier to parent document", + "example": "592c33475f8c1b064c4d1696" + }, + "name": { + "description": "Name of asset type", + "type": "string", + "pattern": "^[a-zA-Z0-9_.]*$", + "example": "Bruce" + }, + "silo": { + "description": "Group or container of asset type", + "type": "string", + "example": "Assets" + }, + "data": { + "description": "Document metadata", + "type": "object", + "example": {"key": "value"} + } + }, + + "definitions": {} +} \ No newline at end of file diff --git a/avalon/schema/asset_type-2.0.json b/avalon/schema/asset_type-2.0.json new file mode 100644 index 000000000..594624a00 --- /dev/null +++ b/avalon/schema/asset_type-2.0.json @@ -0,0 +1,53 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "title": "avalon-core:asset_type-2.0", + "description": "A unit of data", + + "type": "object", + + "additionalProperties": true, + + "required": [ + "schema", + "type", + "name", + "data" + ], + + "properties": { + "schema": { + "description": "Schema identifier for payload", + "type": "string", + "enum": ["avalon-core:asset_type-2.0"], + "example": "avalon-core:asset_type-2.0" + }, + "type": { + "description": "The type of document", + "type": "string", + "enum": ["asset_type"], + "example": "asset_type" + }, + "parent": { + "description": "Unique identifier to parent document", + "example": "592c33475f8c1b064c4d1696" + }, + "name": { + "description": "Name of asset type", + "type": "string", + "pattern": "^[a-zA-Z0-9_.]*$", + "example": "Bruce" + }, + "silo": { + "description": "(Deprecated) Group or container of asset type", + "example": "Assets" + }, + "data": { + "description": "Document metadata", + "type": "object", + "example": {"key": "value"} + } + }, + + "definitions": {} +} diff --git a/avalon/schema/episode-1.0.json b/avalon/schema/episode-1.0.json new file mode 100644 index 000000000..62637e05b --- /dev/null +++ b/avalon/schema/episode-1.0.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "title": "avalon-core:episode-1.0", + "description": "A unit of data", + + "type": "object", + + "additionalProperties": true, + + "required": [ + "schema", + "type", + "name", + "silo", + "data" + ], + + "properties": { + "schema": { + "description": "Schema identifier for payload", + "type": "string", + "enum": ["avalon-core:episode-1.0"], + "example": "avalon-core:episode-1.0" + }, + "type": { + "description": "The type of document", + "type": "string", + "enum": ["episode"], + "example": "episode" + }, + "parent": { + "description": "Unique identifier to parent document", + "example": "592c33475f8c1b064c4d1696" + }, + "name": { + "description": "Name of episode", + "type": "string", + "pattern": "^[a-zA-Z0-9_.]*$", + "example": "Bruce" + }, + "silo": { + "description": "Group or container of episode", + "type": "string", + "example": "episodes" + }, + "data": { + "description": "Document metadata", + "type": "object", + "example": {"key": "value"} + } + }, + + "definitions": {} +} \ No newline at end of file diff --git a/avalon/schema/episode-2.0.json b/avalon/schema/episode-2.0.json new file mode 100644 index 000000000..d0c1122b8 --- /dev/null +++ b/avalon/schema/episode-2.0.json @@ -0,0 +1,53 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "title": "avalon-core:episode-2.0", + "description": "A unit of data", + + "type": "object", + + "additionalProperties": true, + + "required": [ + "schema", + "type", + "name", + "data" + ], + + "properties": { + "schema": { + "description": "Schema identifier for payload", + "type": "string", + "enum": ["avalon-core:episode-2.0"], + "example": "avalon-core:episode-2.0" + }, + "type": { + "description": "The type of document", + "type": "string", + "enum": ["episode"], + "example": "episode" + }, + "parent": { + "description": "Unique identifier to parent document", + "example": "592c33475f8c1b064c4d1696" + }, + "name": { + "description": "Name of episode", + "type": "string", + "pattern": "^[a-zA-Z0-9_.]*$", + "example": "Bruce" + }, + "silo": { + "description": "(Deprecated) Group or container of episode", + "example": "episodes" + }, + "data": { + "description": "Document metadata", + "type": "object", + "example": {"key": "value"} + } + }, + + "definitions": {} +} diff --git a/avalon/schema/sequence-1.0.json b/avalon/schema/sequence-1.0.json new file mode 100644 index 000000000..c08c1eab5 --- /dev/null +++ b/avalon/schema/sequence-1.0.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "title": "avalon-core:sequence-1.0", + "description": "A unit of data", + + "type": "object", + + "additionalProperties": true, + + "required": [ + "schema", + "type", + "name", + "silo", + "data" + ], + + "properties": { + "schema": { + "description": "Schema identifier for payload", + "type": "string", + "enum": ["avalon-core:sequence-1.0"], + "example": "avalon-core:sequence-1.0" + }, + "type": { + "description": "The type of document", + "type": "string", + "enum": ["sequence"], + "example": "sequence" + }, + "parent": { + "description": "Unique identifier to parent document", + "example": "592c33475f8c1b064c4d1696" + }, + "episode": { + "description": "Unique identifier to episode document", + "example": "592c33475f8c1b064c4d1696" + }, + "name": { + "description": "Name of sequence", + "type": "string", + "pattern": "^[a-zA-Z0-9_.]*$", + "example": "Bruce" + }, + "silo": { + "description": "Group or container of sequence", + "type": "string", + "example": "sequences" + }, + "data": { + "description": "Document metadata", + "type": "object", + "example": {"key": "value"} + } + }, + + "definitions": {} +} \ No newline at end of file diff --git a/avalon/schema/sequence-2.0.json b/avalon/schema/sequence-2.0.json new file mode 100644 index 000000000..6ed078961 --- /dev/null +++ b/avalon/schema/sequence-2.0.json @@ -0,0 +1,57 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "title": "avalon-core:sequence-2.0", + "description": "A unit of data", + + "type": "object", + + "additionalProperties": true, + + "required": [ + "schema", + "type", + "name", + "data" + ], + + "properties": { + "schema": { + "description": "Schema identifier for payload", + "type": "string", + "enum": ["avalon-core:sequence-2.0"], + "example": "avalon-core:sequence-2.0" + }, + "type": { + "description": "The type of document", + "type": "string", + "enum": ["sequence"], + "example": "sequence" + }, + "parent": { + "description": "Unique identifier to parent document", + "example": "592c33475f8c1b064c4d1696" + }, + "episode": { + "description": "Unique identifier to episode document", + "example": "592c33475f8c1b064c4d1696" + }, + "name": { + "description": "Name of sequence", + "type": "string", + "pattern": "^[a-zA-Z0-9_.]*$", + "example": "Bruce" + }, + "silo": { + "description": "(Deprecated) Group or container of sequence", + "example": "sequences" + }, + "data": { + "description": "Document metadata", + "type": "object", + "example": {"key": "value"} + } + }, + + "definitions": {} +} diff --git a/avalon/schema/session-2.0.json b/avalon/schema/session-2.0.json index bcbad60ea..6f75f6020 100644 --- a/avalon/schema/session-2.0.json +++ b/avalon/schema/session-2.0.json @@ -27,6 +27,24 @@ "pattern": "^\\w*$", "example": "Hulk" }, + "AVALON_EPISODE": { + "description": "Name of episode", + "type": "string", + "pattern": "^\\w*$", + "example": "EP1" + }, + "AVALON_SEQUENCE": { + "description": "Name of sequence", + "type": "string", + "pattern": "^\\w*$", + "example": "seq0010" + }, + "AVALON_ASSET_TYPE": { + "description": "Name of asset type", + "type": "string", + "pattern": "^\\w*$", + "example": "Char" + }, "AVALON_ASSET": { "description": "Name of asset", "type": "string", diff --git a/avalon/tools/delegates.py b/avalon/tools/delegates.py index 6f191e615..0bcf32c3d 100644 --- a/avalon/tools/delegates.py +++ b/avalon/tools/delegates.py @@ -92,6 +92,7 @@ def paint(self, painter, option, index): else: QtWidgets.QStyledItemDelegate.paint(self, painter, option, index) + def pretty_date(t, now=None, strftime="%b %d %Y %H:%M"): """Parse datetime to readable timestamp @@ -201,3 +202,65 @@ def displayText(self, value, locale): return return pretty_timestamp(value) + + +class ButtonDelegate(QtWidgets.QStyledItemDelegate): + clicked = QtCore.Signal(QtCore.QModelIndex) + + def __init__(self, parent=None): + super(ButtonDelegate, self).__init__(parent) + self.pressed = {} + + def paint(self, painter, option, index): + text = index.data(QtCore.Qt.DisplayRole) + icon = index.data(QtCore.Qt.DecorationRole) + if not text and not icon: + return + + bg_color_role = QtGui.QPalette.Window + text_color_role = QtGui.QPalette.Mid + button_color_role = QtGui.QPalette.Button + + if option.state & QtWidgets.QStyle.State_Enabled: + color_group = QtGui.QPalette.Normal + if option.state & QtWidgets.QStyle.State_Selected: + bg_color_role = QtGui.QPalette.Highlight + text_color_role = QtGui.QPalette.Midlight + elif option.state & QtWidgets.QStyle.State_MouseOver: + text_color_role = QtGui.QPalette.Midlight + if self.pressed.get(index): + button_color_role = QtGui.QPalette.Dark + else: + color_group = QtGui.QPalette.Disabled + + text_color = option.palette.color(color_group, text_color_role) + bg_color = option.palette.color(color_group, bg_color_role) + button_color = option.palette.color(color_group, button_color_role) + + pen = QtGui.QPen(text_color) + pen.setWidth(1) + painter.setPen(pen) + + rect = option.rect.adjusted(5, 5, -5, -5) + path = QtGui.QPainterPath() + path.addRoundedRect(rect, 2, 2) + + painter.fillRect(option.rect, bg_color) + painter.fillPath(path, button_color) + painter.drawPath(path) + + if text: + painter.drawText(rect, QtCore.Qt.AlignHCenter, text) + if icon: + icon.paint(painter, rect) + + def editorEvent(self, event, editor, model, index): + if index.flags() & QtCore.Qt.ItemIsEnabled: + if event.type() == QtCore.QEvent.MouseButtonPress: + self.pressed[index] = True + return True + if event.type() == QtCore.QEvent.MouseButtonRelease: + self.pressed[index] = False + self.clicked.emit(index) + return True + return super(ButtonDelegate, self).editorEvent(event, editor, model, index) diff --git a/avalon/tools/lib.py b/avalon/tools/lib.py index 7ddddcb6f..fd8f4609a 100644 --- a/avalon/tools/lib.py +++ b/avalon/tools/lib.py @@ -302,6 +302,9 @@ def get_family_cached_config(name): config = FAMILY_CONFIG_CACHE if 'reveries.shotgun.' in name: return config.get(name, config.get("shotgun_icon", None)) + if '.usd' in name: + return config.get(name, config.get("usd_icon", None)) + return config.get(name, config.get("__default__", None)) @@ -346,10 +349,13 @@ def refresh_family_config_cache(): # Replace icons with a Qt icon we can use in the user interfaces default_icon = qtawesome.icon("fa.folder", color=FAMILY_ICON_COLOR) shotgun_icon = QtGui.QIcon(os.path.join(ICON_DIR, 'shotgun.png')) + usd_icon = QtGui.QIcon(os.path.join(ICON_DIR, 'usd.png')) for name, family in families.items(): # Set family icon icon = family.get("icon", None) - if icon: + if icon == 'usd': + family["icon"] = usd_icon + elif icon: _icon_name = "fa.{}".format(icon) if len(icon.split(".")) > 1: _icon_name = icon @@ -367,6 +373,7 @@ def refresh_family_config_cache(): # Default configuration families["__default__"] = {"icon": default_icon} families["shotgun_icon"] = {"icon": shotgun_icon} + families["usd_icon"] = {"icon": usd_icon} FAMILY_CONFIG_CACHE.clear() FAMILY_CONFIG_CACHE.update(families) @@ -544,3 +551,25 @@ def to_trash(item, **kwargs): def create_silo(data): from reveries.common.publish.create_silo import create return create(data) + + +def get_latest_grab_source_version(grab_log): + import pymongo + + grab_from = grab_log.get("grab_from") + source_id = grab_log.get("source_id") + + if not grab_from or not source_id: + return + + timeout = int(io.Session["AVALON_TIMEOUT"]) + mongo_client = pymongo.MongoClient( + io.Session["AVALON_MONGO"], serverSelectionTimeoutMS=timeout) + database = mongo_client[io.Session["AVALON_DB"]] + collection = database[grab_from] + + from_version = collection.find_one({"type": "version", "_id": source_id}) + if from_version: + latest_version = collection.find_one({ + "type": "version", "parent": from_version["parent"]}, sort=[("name", -1)]) + return latest_version diff --git a/avalon/tools/loader/app.py b/avalon/tools/loader/app.py index db1d65fa7..374a293ce 100644 --- a/avalon/tools/loader/app.py +++ b/avalon/tools/loader/app.py @@ -1,15 +1,24 @@ import sys import time +import getpass +import copy from ...vendor.Qt import QtWidgets, QtCore from ... import api, io, style -from ..models import AssetModel +from ..models import AssetModel, TreeModel from ..widgets import AssetWidget from .. import lib from .widgets import SubsetWidget, VersionWidget, FamilyListWidget, CommentWidget +from shot_manager.tools.inventory_publish.utils import ( + SupportFamilies, + update_st_data, + update_pc_data +) +from shot_manager.tools.inventory_publish.publish import InventoryPublisher + module = sys.modules[__name__] module.window = None @@ -120,6 +129,7 @@ def __init__(self, parent=None): subsets.active_changed.connect(self.on_subsetschanged) subsets.version_changed.connect(self.on_versionschanged) + subsets.shot_inventory_clicked.connect(self.on_add_to_shot_inventory) # Defaults self.resize(1330, 700) @@ -144,6 +154,129 @@ def on_versionschanged(self, *args): self.echo("Fetching version..") lib.schedule(self._versionschanged, 150, channel="mongo") + def on_add_to_shot_inventory(self, index): + subsets_widget = self.data["model"]["subsets"] + subsets_model = subsets_widget.model + inventory_data = copy.deepcopy(subsets_model.inventory_data) + item = index.data(TreeModel.ItemRole) + subset_name = item["name"] + _id = str(item["_id"]) + current_inside = _id in inventory_data + + accept = QtWidgets.QMessageBox.Ok + buttons = accept | QtWidgets.QMessageBox.Cancel + + if current_inside: + title = "Remove from Shot Inventory" + message = ('Are you sure you want to remove ' + '"{}" from shot inventory?'.format(subset_name)) + else: + title = "Add to Shot Inventory" + message = ('Are you sure you want to add ' + '"{}" to shot inventory?'.format(subset_name)) + state = QtWidgets.QMessageBox.question(self, title, + message, + buttons=buttons, + defaultButton=accept) + + if state != accept: + return + + if current_inside: + del inventory_data[_id] + comment = "Publish from loader. " \ + "Remove {} by {}.".format(subset_name, getpass.getuser()) + else: + comment = "Publish from loader. " \ + "Add {} by {}.".format(subset_name, getpass.getuser()) + + family = item["family"] + inventory_data.update({ + _id: { + 'subset': subset_name, + 'type': SupportFamilies[family]['type'], + 'icon': lib.get_family_cached_config( + family).get('icon_name', 'fa.folder') + } + }) + # Update lookdev data + if family in ['reveries.pointcache']: + inventory_data = update_pc_data(inventory_data, _id) + if 'look' not in inventory_data[_id].keys(): + title = "No look for this pointcache" + message = ('No look for this pointcache. ' + 'Continue add to shot inventory?') + state = QtWidgets.QMessageBox.question(self, title, + message, + buttons=buttons, + defaultButton=accept) + if state != accept: + return + + comment += "\nNo look for this pointcache." + + if family in ['reveries.setdress']: + inventory_data = update_st_data(inventory_data, _id) + + if 'look' not in inventory_data[_id].keys(): + title = "No look for this setdress" + message = ('No look for this setdress. ' + 'Continue add to shot inventory?') + state = QtWidgets.QMessageBox.question(self, title, + message, + buttons=buttons, + defaultButton=accept) + if state != accept: + return + + comment += "\nNo look for this setdress." + + pub_data = {} + for _id in inventory_data: + _data = inventory_data[_id] + _tmp_data = { + 'type': _data['type'], + 'count': 1, + 'info': _data.get('info', '') + } + + if 'look' in list(_data.keys()): + if _data['type'] == 'pointcache': + _tmp_data['look'] = {} + for _label in _data['look']: + _tmp_data['look'].update({ + _label: { + 'id': str(_data['look'][_label]['id']), + 'changed': _data['look'][_label].get('changed', False), + 'info': _data['look'][_label].get('info', '') + } + }) + if _data['type'] == 'setdress': + _tmp_data['look'] = {} + for ns, _st_look_data in _data['look'].items(): + look_data = _st_look_data + look_data['namespace'] = ns + _tmp_data['look'].update({ + _st_look_data['container_id']: look_data + }) + + pub_data[_id] = copy.deepcopy(_tmp_data) + + asset_name = self.data["state"]["context"]["asset"] + publisher = InventoryPublisher( + shot_name=asset_name, + shot_data=pub_data, + comment=comment + ) + if publisher.do_publish(): + message = 'Shot inventory published success.\n\n' + else: + message = 'Shot inventory published failed.\n\n' + + QtWidgets.QMessageBox.information(self, title, + message, QtWidgets.QMessageBox.Ok) + lib.schedule(self._refresh, 50, channel="mongo") + def set_context(self, context, refresh=True): self.echo("Setting context: {}".format(context)) lib.schedule(lambda: self._set_context(context, refresh=refresh), diff --git a/avalon/tools/loader/model.py b/avalon/tools/loader/model.py index 006c29185..0d3afaf75 100644 --- a/avalon/tools/loader/model.py +++ b/avalon/tools/loader/model.py @@ -1,5 +1,6 @@ import logging +import traceback from ... import io, style, pipeline from ...vendor.Qt import QtCore @@ -8,6 +9,10 @@ from ..models import TreeModel, Item from .. import lib +from shot_manager.tools.inventory_publish.utils import ( + SupportFamilies, + get_inventory_data, +) log = logging.getLogger(__name__) @@ -43,7 +48,8 @@ class SubsetsModel(TreeModel): "frames", "duration", "handles", - "step" + "step", + "shot inventory" ] SortAscendingRole = QtCore.Qt.UserRole + 2 @@ -71,10 +77,15 @@ def __init__(self, grouping=True, parent=None): "review.status.rendering": qtawesome.icon("fa.thermometer-2", color="#f9a825"), "review.status.pending": qtawesome.icon("fa.thumbs-up", color="#464646"), "review.status.approved": qtawesome.icon("fa.thumbs-up", color="#7cb342"), + + "inventory.inside": qtawesome.icon("fa.shopping-basket", color="#d77681"), + "inventory.outside": qtawesome.icon("fa.shopping-basket", color="#464646"), + "inventory.failed": qtawesome.icon("fa.question", color="#e53935"), } self._doc_fetching_thread = None self._doc_fetching_stop = False self._doc_payload = list() + self.inventory_data = dict() self.doc_fetched.connect(self.on_doc_fetched) @@ -121,6 +132,8 @@ def set_version(self, index, version): version_data = version.get("data", dict()) # Compute frame ranges (if data is present) + render_frames = version_data.get("renderFrames", None) + render_frame_list = version_data.get("renderFrameList", None) frame_start = version_data.get( "frameStart", # backwards compatibility @@ -139,7 +152,10 @@ def set_version(self, index, version): else: handles = version_data.get("handles", None) - if frame_start is not None and frame_end is not None: + if render_frames is not None: + frames = render_frames + duration = len(render_frame_list) + elif frame_start is not None and frame_end is not None: # Remove superfluous zeros from numbers (3.0 -> 3) to improve # readability for most frame ranges start_clean = ("%f" % frame_start).rstrip("0").rstrip(".") @@ -254,6 +270,15 @@ def _fetch(): self._doc_payload[:] = [(subset, versions.get(subset["_id"])) for subset in _subsets] + + shot_name = io.find_one({'_id': self._asset_id})["name"] + try: + self.inventory_data = get_inventory_data(shot_name) + except Exception: + print("Error: Get inventory data failed!") + print(traceback.format_exc()) + self.inventory_data = "Failed" + self.doc_fetched.emit() self._doc_payload[:] = [] @@ -353,6 +378,21 @@ def data(self, index, role): else: return "render progress: %d%%" % item["progress_percent"] + if index.column() == self.columns_index["shot inventory"]: + item = index.internalPointer() + _id = item.get("_id") + family = item.get("family", None) + if not _id: + return None + elif family not in list(SupportFamilies.keys()): + return "Shot inventory isn\'t support this family." + elif self.inventory_data == "Failed": + return "Get inventory data failed! See log..." + elif str(_id) in self.inventory_data: + return "Click to remove subset from shot inventory." + else: + return "Click to add subset into shot inventory." + if role == QtCore.Qt.DecorationRole: # Add icon to subset column @@ -382,6 +422,22 @@ def data(self, index, role): else: return self._icons["version.%s" % item["progress"]] + # Add icon to shot inventory column + if index.column() == self.columns_index["shot inventory"]: + item = index.internalPointer() + _id = item.get("_id") + family = item.get("family", None) + if not _id: + return None + elif family not in list(SupportFamilies.keys()): + return None + elif self.inventory_data == "Failed": + return self._icons["inventory.failed"] + elif str(_id) in self.inventory_data: + return self._icons["inventory.inside"] + else: + return self._icons["inventory.outside"] + if role == self.SortDescendingRole: item = index.internalPointer() if item.get("isGroup"): @@ -413,6 +469,13 @@ def data(self, index, role): def flags(self, index): flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + if index.column() == self.columns_index["shot inventory"]: + item = index.internalPointer() + family = item.get("family", None) + if family not in list(SupportFamilies.keys()) or \ + self.inventory_data == "Failed": + flags = QtCore.Qt.ItemIsSelectable + # Make the version column editable if index.column() == self.columns_index["version"]: flags |= QtCore.Qt.ItemIsEditable diff --git a/avalon/tools/loader/widgets.py b/avalon/tools/loader/widgets.py index 70feacbfc..de2c8b693 100644 --- a/avalon/tools/loader/widgets.py +++ b/avalon/tools/loader/widgets.py @@ -12,7 +12,7 @@ from ... import style from .. import lib as tools_lib -from ..delegates import VersionDelegate +from ..delegates import VersionDelegate, ButtonDelegate from ..widgets import OptionalMenu, OptionalAction, OptionDialog from .model import ( @@ -20,7 +20,7 @@ SubsetFilterProxyModel, FamiliesFilterProxyModel, ) -from ..delegates import PrettyTimeDelegate +from ..delegates import PrettyTimeDelegate, pretty_timestamp local_encoding = locale.getlocale()[1] @@ -30,6 +30,7 @@ class SubsetWidget(QtWidgets.QWidget): active_changed = QtCore.Signal() # active index changed version_changed = QtCore.Signal() # version state changed for a subset + shot_inventory_clicked = QtCore.Signal(QtCore.QModelIndex) def __init__(self, enable_grouping=True, parent=None): super(SubsetWidget, self).__init__(parent=parent) @@ -58,6 +59,7 @@ def __init__(self, enable_grouping=True, parent=None): } """) view.setAllColumnsShowFocus(True) + view.setMouseTracking(True) # Set view delegates version_delegate = VersionDelegate() @@ -68,6 +70,10 @@ def __init__(self, enable_grouping=True, parent=None): column = model.Columns.index("time") view.setItemDelegateForColumn(column, time_delegate) + shot_inventory_delegate = ButtonDelegate() + column = model.Columns.index("shot inventory") + view.setItemDelegateForColumn(column, shot_inventory_delegate) + layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addLayout(top_bar_layout) @@ -82,7 +88,8 @@ def __init__(self, enable_grouping=True, parent=None): self.data = { "delegates": { "version": version_delegate, - "time": time_delegate + "time": time_delegate, + "shot inventory": shot_inventory_delegate }, "state": { "groupable": groupable @@ -105,18 +112,20 @@ def __init__(self, enable_grouping=True, parent=None): view.setColumnWidth(0, 240) # subset view.setColumnWidth(1, 120) # family - view.setColumnWidth(2, 100) # version + view.setColumnWidth(2, 80) # version view.setColumnWidth(3, 120) # time - view.setColumnWidth(4, 100) # author - view.setColumnWidth(5, 80) # frames + view.setColumnWidth(4, 50) # author + view.setColumnWidth(5, 70) # frames view.setColumnWidth(6, 60) # duration view.setColumnWidth(7, 50) # handles - view.setColumnWidth(8, 50) # step + view.setColumnWidth(8, 40) # step + view.setColumnWidth(9, 90) # shot inventory selection = view.selectionModel() selection.selectionChanged.connect(self.active_changed) version_delegate.version_changed.connect(self.version_changed) + shot_inventory_delegate.clicked.connect(self.shot_inventory_clicked) groupable.stateChanged.connect(self.set_grouping) @@ -549,6 +558,11 @@ def set_version(self, version_id): source = version["data"].get("source", None) source_label = source if source else "No source" + grab_log = version["data"].get("grab_log", {}) + grab_from = grab_log.get("grab_from", "") + grab_user = grab_log.get("grab_user", "") + grab_time = grab_log.get("grab_time", "") + # Store source and raw data self.data["source"] = source self.data["raw"] = version @@ -562,13 +576,22 @@ def set_version(self, version_id): } _version_info = u""" -