diff --git a/colorbleed/fusion/lib.py b/colorbleed/fusion/lib.py new file mode 100644 index 0000000000..7186cd2cac --- /dev/null +++ b/colorbleed/fusion/lib.py @@ -0,0 +1,40 @@ +import sys + +import avalon.fusion + + +self = sys.modules[__name__] +self._project = None + + +def update_frame_range(start, end, comp=None, set_render_range=True): + """Set Fusion comp's start and end frame range + + Args: + start (float, int): start frame + end (float, int): end frame + comp (object, Optional): comp object from fusion + set_render_range (bool, Optional): When True this will also set the + composition's render start and end frame. + + Returns: + None + + """ + + if not comp: + comp = avalon.fusion.get_current_comp() + + attrs = { + "COMPN_GlobalStart": start, + "COMPN_GlobalEnd": end + } + + if set_render_range: + attrs.update({ + "COMPN_RenderStart": start, + "COMPN_RenderEnd": end + }) + + with avalon.fusion.comp_lock_and_undo_chunk(comp): + comp.SetAttrs(attrs) diff --git a/colorbleed/lib.py b/colorbleed/lib.py index b57c9c4a83..14ad6884b5 100644 --- a/colorbleed/lib.py +++ b/colorbleed/lib.py @@ -144,3 +144,73 @@ def version_up(filepath): log.info("New version %s" % new_label) return new_filename + + +def switch_item(container, + asset_name=None, + subset_name=None, + representation_name=None): + """Switch container asset, subset or representation of a container by name. + + It'll always switch to the latest version - of course a different + approach could be implemented. + + Args: + container (dict): data of the item to switch with + asset_name (str): name of the asset + subset_name (str): name of the subset + representation_name (str): name of the representation + + Returns: + dict + + """ + + if all(not x for x in [asset_name, subset_name, representation_name]): + raise ValueError("Must have at least one change provided to switch.") + + # Collect any of current asset, subset and representation if not provided + # so we can use the original name from those. + if any(not x for x in [asset_name, subset_name, representation_name]): + _id = io.ObjectId(container["representation"]) + representation = io.find_one({"type": "representation", "_id": _id}) + version, subset, asset, project = io.parenthood(representation) + + if asset_name is None: + asset_name = asset["name"] + + if subset_name is None: + subset_name = subset["name"] + + if representation_name is None: + representation_name = representation["name"] + + # Find the new one + asset = io.find_one({"name": asset_name, "type": "asset"}) + assert asset, ("Could not find asset in the database with the name " + "'%s'" % asset_name) + + subset = io.find_one({"name": subset_name, + "type": "subset", + "parent": asset["_id"]}) + assert subset, ("Could not find subset in the database with the name " + "'%s'" % subset_name) + + version = io.find_one({"type": "version", + "parent": subset["_id"]}, + sort=[('name', -1)]) + + assert version, "Could not find a version for {}.{}".format( + asset_name, subset_name + ) + + representation = io.find_one({"name": representation_name, + "type": "representation", + "parent": version["_id"]}) + + assert representation, ("Could not find representation in the database with " + "the name '%s'" % representation_name) + + avalon.api.switch(container, representation) + + return representation diff --git a/colorbleed/plugins/fusion/load/actions.py b/colorbleed/plugins/fusion/load/actions.py index 68f66775a8..c986229748 100644 --- a/colorbleed/plugins/fusion/load/actions.py +++ b/colorbleed/plugins/fusion/load/actions.py @@ -5,37 +5,6 @@ from avalon import api -def _set_frame_range(start, end, set_render_range=True): - """Set Fusion comp's start and end frame range - - Attrs: - set_render_range (bool, Optional): When True this will also set the - composition's render start and end frame. - - Returns: - None - - """ - - from avalon.fusion import get_current_comp, comp_lock_and_undo_chunk - - comp = get_current_comp() - - attrs = { - "COMPN_GlobalStart": start, - "COMPN_GlobalEnd": end - } - - if set_render_range: - attrs.update({ - "COMPN_RenderStart": start, - "COMPN_RenderEnd": end - }) - - with comp_lock_and_undo_chunk(comp): - comp.SetAttrs(attrs) - - class FusionSetFrameRangeLoader(api.Loader): """Specific loader of Alembic for the avalon.animation family""" @@ -53,6 +22,8 @@ class FusionSetFrameRangeLoader(api.Loader): def load(self, context, name, namespace, data): + from colorbleed.fusion import lib + version = context['version'] version_data = version.get("data", {}) @@ -64,7 +35,7 @@ class FusionSetFrameRangeLoader(api.Loader): "end frame data is missing..") return - _set_frame_range(start, end) + lib.update_frame_range(start, end) class FusionSetFrameRangeWithHandlesLoader(api.Loader): @@ -84,6 +55,8 @@ class FusionSetFrameRangeWithHandlesLoader(api.Loader): def load(self, context, name, namespace, data): + from colorbleed.fusion import lib + version = context['version'] version_data = version.get("data", {}) @@ -100,4 +73,4 @@ class FusionSetFrameRangeWithHandlesLoader(api.Loader): start -= handles end += handles - _set_frame_range(start, end) + lib.update_frame_range(start, end) diff --git a/colorbleed/plugins/maya/load/load_alembic.py b/colorbleed/plugins/maya/load/load_alembic.py index e9f4ce284b..afea761ab8 100644 --- a/colorbleed/plugins/maya/load/load_alembic.py +++ b/colorbleed/plugins/maya/load/load_alembic.py @@ -28,3 +28,6 @@ class AbcLoader(colorbleed.maya.plugin.ReferenceLoader): self[:] = nodes return nodes + + def switch(self, container, representation): + self.update(container, representation) diff --git a/colorbleed/plugins/maya/load/load_camera.py b/colorbleed/plugins/maya/load/load_camera.py index 7bb095df67..c1e1b4bf25 100644 --- a/colorbleed/plugins/maya/load/load_camera.py +++ b/colorbleed/plugins/maya/load/load_camera.py @@ -14,7 +14,6 @@ class CameraLoader(colorbleed.maya.plugin.ReferenceLoader): def process_reference(self, context, name, namespace, data): import maya.cmds as cmds - # import pprint # Get family type from the context cmds.loadPlugin("AbcImport.mll", quiet=True) @@ -41,3 +40,6 @@ class CameraLoader(colorbleed.maya.plugin.ReferenceLoader): self[:] = nodes return nodes + + def switch(self, container, representation): + self.update(container, representation) diff --git a/colorbleed/plugins/maya/load/load_look.py b/colorbleed/plugins/maya/load/load_look.py index 16428ef034..cc13918ba1 100644 --- a/colorbleed/plugins/maya/load/load_look.py +++ b/colorbleed/plugins/maya/load/load_look.py @@ -35,3 +35,6 @@ class LookLoader(colorbleed.maya.plugin.ReferenceLoader): returnNewNodes=True) self[:] = nodes + + def switch(self, container, representation): + self.update(container, representation) diff --git a/colorbleed/plugins/maya/load/load_mayaascii.py b/colorbleed/plugins/maya/load/load_mayaascii.py index 07bf1ea836..dd86cc97b5 100644 --- a/colorbleed/plugins/maya/load/load_mayaascii.py +++ b/colorbleed/plugins/maya/load/load_mayaascii.py @@ -28,3 +28,6 @@ class MayaAsciiLoader(colorbleed.maya.plugin.ReferenceLoader): self[:] = nodes return nodes + + def switch(self, container, representation): + self.update(container, representation) diff --git a/colorbleed/plugins/maya/load/load_model.py b/colorbleed/plugins/maya/load/load_model.py index ff512610a3..eb76bd9ada 100644 --- a/colorbleed/plugins/maya/load/load_model.py +++ b/colorbleed/plugins/maya/load/load_model.py @@ -30,6 +30,9 @@ class ModelLoader(colorbleed.maya.plugin.ReferenceLoader): return nodes + def switch(self, container, representation): + self.update(container, representation) + class ImportModelLoader(api.Loader): """An ImportModelLoader for Maya @@ -200,6 +203,9 @@ class GpuCacheLoader(api.Loader): str(representation["_id"]), type="string") + def switch(self, container, representation): + self.update(container, representation) + def remove(self, container): import maya.cmds as cmds members = cmds.sets(container['objectName'], query=True) diff --git a/colorbleed/plugins/maya/load/load_rig.py b/colorbleed/plugins/maya/load/load_rig.py index cb4b5f2f7c..1f3e83f60d 100644 --- a/colorbleed/plugins/maya/load/load_rig.py +++ b/colorbleed/plugins/maya/load/load_rig.py @@ -65,3 +65,6 @@ class RigLoader(colorbleed.maya.plugin.ReferenceLoader): family="colorbleed.animation", options={"useSelection": True}, data={"dependencies": dependency}) + + def switch(self, container, representation): + self.update(container, representation) diff --git a/colorbleed/plugins/maya/load/load_yeti_cache.py b/colorbleed/plugins/maya/load/load_yeti_cache.py index 0544868340..ec7ab11474 100644 --- a/colorbleed/plugins/maya/load/load_yeti_cache.py +++ b/colorbleed/plugins/maya/load/load_yeti_cache.py @@ -108,6 +108,9 @@ class YetiCacheLoader(api.Loader): str(representation["_id"]), type="string") + def switch(self, container, representation): + self.update(container, representation) + # helper functions def create_namespace(self, asset): diff --git a/colorbleed/plugins/maya/publish/validate_mesh_shader_connections.py b/colorbleed/plugins/maya/publish/validate_mesh_shader_connections.py new file mode 100644 index 0000000000..5f44bf1f9f --- /dev/null +++ b/colorbleed/plugins/maya/publish/validate_mesh_shader_connections.py @@ -0,0 +1,111 @@ +from maya import cmds + +import pyblish.api +import colorbleed.api + + +def pairs(iterable): + """Iterate over iterable per group of two""" + a = iter(iterable) + for i, y in zip(a, a): + yield i, y + + +def get_invalid_sets(shape): + """Get sets that are considered related but do not contain the shape. + + In some scenarios Maya keeps connections to multiple shaders + even if just a single one is assigned on the full object. + + These are related sets returned by `maya.cmds.listSets` that don't + actually have the shape as member. + + """ + + invalid = [] + sets = cmds.listSets(object=shape, t=1, extendToShape=False) + for s in sets: + members = cmds.sets(s, query=True, nodesOnly=True) + if not members: + invalid.append(s) + continue + + members = set(cmds.ls(members, long=True)) + if shape not in members: + invalid.append(s) + + return invalid + + +def disconnect(node_a, node_b): + """Remove all connections between node a and b.""" + + # Disconnect outputs + outputs = cmds.listConnections(node_a, + plugs=True, + connections=True, + source=False, + destination=True) + for output, destination in pairs(outputs): + if destination.split(".", 1)[0] == node_b: + cmds.disconnectAttr(output, destination) + + # Disconnect inputs + inputs = cmds.listConnections(node_a, + plugs=True, + connections=True, + source=True, + destination=False) + for input, source in pairs(inputs): + if source.split(".", 1)[0] == node_b: + cmds.disconnectAttr(source, input) + + +class ValidateMeshShaderConnections(pyblish.api.InstancePlugin): + """Ensure mesh shading engine connections are valid. + + In some scenarios Maya keeps connections to multiple shaders even if just + a single one is assigned on the shape. + + These are related sets returned by `maya.cmds.listSets` that don't + actually have the shape as member. + + """ + + order = colorbleed.api.ValidateMeshOrder + hosts = ['maya'] + families = ['colorbleed.model'] + label = "Mesh Shader Connections" + actions = [colorbleed.api.SelectInvalidAction, + colorbleed.api.RepairAction] + + def process(self, instance): + """Process all the nodes in the instance 'objectSet'""" + + invalid = self.get_invalid(instance) + + if invalid: + raise RuntimeError("Shapes found with invalid shader " + "connections: {0}".format(invalid)) + + @staticmethod + def get_invalid(instance): + + shapes = cmds.ls(instance[:], dag=1, leaf=1, shapes=1, long=True) + shapes = cmds.ls(shapes, shapes=True, noIntermediate=True, long=True) + + invalid = [] + for shape in shapes: + if get_invalid_sets(shape): + invalid.append(shape) + + return invalid + + @classmethod + def repair(cls, instance): + + shapes = cls.get_invalid(instance) + for shape in shapes: + invalid_sets = get_invalid_sets(shape) + for set_node in invalid_sets: + disconnect(shape, set_node) diff --git a/colorbleed/scripts/fusion_switch_shot.py b/colorbleed/scripts/fusion_switch_shot.py new file mode 100644 index 0000000000..1a7e6bfed6 --- /dev/null +++ b/colorbleed/scripts/fusion_switch_shot.py @@ -0,0 +1,239 @@ +import os +import re +import sys +import logging + +# Pipeline imports +from avalon import api, io, pipeline +import avalon.fusion + +# Config imports +import colorbleed.lib as colorbleed +import colorbleed.fusion.lib as fusion_lib + +log = logging.getLogger("Update Slap Comp") + +self = sys.modules[__name__] +self._project = None + + +def _format_version_folder(folder): + """Format a version folder based on the filepath + + Assumption here is made that, if the path does not exists the folder + will be "v001" + + Args: + folder: file path to a folder + + Returns: + str: new version folder name + """ + + if not os.path.isdir(folder): + return "v001" + + re_version = re.compile("v\d+$") + versions = [i for i in os.listdir(folder) if os.path.isdir(i) + and re_version.match(i)] + + new_version = int(max(versions)[1:]) + 1 # ensure the "v" is not included + version_folder = "v{:03d}".format(new_version) + + return version_folder + + +def _get_work_folder(session): + """Convenience function to get the work folder path of the current asset""" + + # Get new filename, create path based on asset and work template + template_work = self._project["config"]["template"]["work"] + work_path = pipeline._format_work_template(template_work, session) + + return os.path.normpath(work_path) + + +def _get_fusion_instance(): + fusion = getattr(sys.modules["__main__"], "fusion", None) + if fusion is None: + try: + # Support for FuScript.exe, BlackmagicFusion module for py2 only + import BlackmagicFusion as bmf + fusion = bmf.scriptapp("Fusion") + except ImportError: + raise RuntimeError("Could not find a Fusion instance") + return fusion + + +def _format_filepath(session): + + project = session["AVALON_PROJECT"] + asset = session["AVALON_ASSET"] + + # Save updated slap comp + work_path = _get_work_folder(session) + walk_to_dir = os.path.join(work_path, "scenes", "slapcomp") + slapcomp_dir = os.path.abspath(walk_to_dir) + + # Ensure destination exists + if not os.path.isdir(slapcomp_dir): + log.warning("Folder did not exist, creating folder structure") + os.makedirs(slapcomp_dir) + + # Compute output path + new_filename = "{}_{}_slapcomp_v001.comp".format(project, asset) + new_filepath = os.path.join(slapcomp_dir, new_filename) + + # Create new unqiue filepath + if os.path.exists(new_filepath): + new_filepath = colorbleed.version_up(new_filepath) + + return new_filepath + + +def _update_savers(comp, session): + """Update all savers of the current comp to ensure the output is correct + + Args: + comp (object): current comp instance + session (dict): the current Avalon session + + Returns: + None + """ + + new_work = _get_work_folder(session) + renders = os.path.join(new_work, "renders") + version_folder = _format_version_folder(renders) + renders_version = os.path.join(renders, version_folder) + + comp.Print("New renders to: %s\n" % renders) + + with avalon.fusion.comp_lock_and_undo_chunk(comp): + savers = comp.GetToolList(False, "Saver").values() + for saver in savers: + filepath = saver.GetAttrs("TOOLST_Clip_Name")[1.0] + filename = os.path.basename(filepath) + new_path = os.path.join(renders_version, filename) + saver["Clip"] = new_path + + +def update_frame_range(comp, representations): + """Update the frame range of the comp and render length + + The start and end frame are based on the lowest start frame and the highest + end frame + + Args: + comp (object): current focused comp + representations (list) collection of dicts + + Returns: + None + + """ + + version_ids = [r["parent"] for r in representations] + versions = io.find({"type": "version", "_id": {"$in": version_ids}}) + versions = list(versions) + + start = min(v["data"]["startFrame"] for v in versions) + end = max(v["data"]["endFrame"] for v in versions) + + fusion_lib.update_frame_range(start, end, comp=comp) + + +def switch(filepath, asset_name, new=True): + """Switch the current containers of the file to the other asset (shot) + + Args: + filepath (str): file path of the comp file + asset_name (str): name of the asset (shot) + new (bool): Save updated comp under a different name + + Returns: + comp path (str): new filepath of the updated comp + + """ + + # Ensure filename is absolute + if not os.path.abspath(filepath): + filepath = os.path.abspath(filepath) + + # Get current project + self._project = io.find_one({"type": "project", + "name": api.Session["AVALON_PROJECT"]}) + + # Assert asset name exists + # It is better to do this here then to wait till switch_shot does it + asset = io.find_one({"type": "asset", "name": asset_name}) + assert asset, "Could not find '%s' in the database" % asset_name + + # Go to comp + fusion = _get_fusion_instance() + + current_comp = fusion.LoadComp(filepath) + assert current_comp is not None, "Fusion could not load '%s'" % filepath + + host = api.registered_host() + containers = list(host.ls()) + assert containers, "Nothing to update" + + representations = [] + for container in containers: + try: + representation = colorbleed.switch_item(container, + asset_name=asset_name) + representations.append(representation) + current_comp.Print(str(representation["_id"]) + "\n") + except Exception as e: + current_comp.Print("Error in switching! %s\n" % e.message) + + message = "Switched %i Loaders of the %i\n" % (len(representations), + len(containers)) + current_comp.Print(message) + + # Build the session to switch to + switch_to_session = api.Session.copy() + switch_to_session["AVALON_ASSET"] = asset['name'] + + if new: + comp_path = _format_filepath(switch_to_session) + + # Update savers output based on new session + _update_savers(current_comp, switch_to_session) + else: + comp_path = colorbleed.version_up(filepath) + + current_comp.Print(comp_path) + + current_comp.Print("\nUpdating frame range") + update_frame_range(current_comp, representations) + + current_comp.Save(comp_path) + + return comp_path + + +if __name__ == '__main__': + + import argparse + + parser = argparse.ArgumentParser(description="Switch to a shot within an" + "existing comp file") + + parser.add_argument("--file_path", + type=str, + default=True, + help="File path of the comp to use") + parser.add_argument("--asset_name", + type=str, + default=True, + help="Name of the asset (shot) to switch") + + args, unknown = parser.parse_args() + + api.install(avalon.fusion) + switch(args.file_path, args.asset_name) + + sys.exit(0) diff --git a/setup/fusion/scripts/Comp/colorbleed/switch_ui.py b/setup/fusion/scripts/Comp/colorbleed/switch_ui.py new file mode 100644 index 0000000000..1b0d4a1b82 --- /dev/null +++ b/setup/fusion/scripts/Comp/colorbleed/switch_ui.py @@ -0,0 +1,213 @@ +import os +import glob +import logging + +import avalon.io as io +import avalon.api as api +import avalon.pipeline as pipeline +import avalon.fusion +import avalon.style as style +from avalon.vendor.Qt import QtWidgets, QtCore +from avalon.vendor import qtawesome as qta + + +log = logging.getLogger("Fusion Switch Shot") + + +class App(QtWidgets.QWidget): + + def __init__(self, parent=None): + + ################################################ + # |---------------------| |------------------| # + # |Comp | |Asset | # + # |[..][ v]| |[ v]| # + # |---------------------| |------------------| # + # | Update existing comp [ ] | # + # |------------------------------------------| # + # | Switch | # + # |------------------------------------------| # + ################################################ + + QtWidgets.QWidget.__init__(self, parent) + + layout = QtWidgets.QVBoxLayout() + + # Comp related input + comp_hlayout = QtWidgets.QHBoxLayout() + comp_label = QtWidgets.QLabel("Comp file") + comp_label.setFixedWidth(50) + comp_box = QtWidgets.QComboBox() + + button_icon = qta.icon("fa.folder", color="white") + open_from_dir = QtWidgets.QPushButton() + open_from_dir.setIcon(button_icon) + + comp_box.setFixedHeight(25) + open_from_dir.setFixedWidth(25) + open_from_dir.setFixedHeight(25) + + comp_hlayout.addWidget(comp_label) + comp_hlayout.addWidget(comp_box) + comp_hlayout.addWidget(open_from_dir) + + # Asset related input + asset_hlayout = QtWidgets.QHBoxLayout() + asset_label = QtWidgets.QLabel("Shot") + asset_label.setFixedWidth(50) + + asset_box = QtWidgets.QComboBox() + asset_box.setLineEdit(QtWidgets.QLineEdit()) + asset_box.setFixedHeight(25) + + refresh_icon = qta.icon("fa.refresh", color="white") + refresh_btn = QtWidgets.QPushButton() + refresh_btn.setIcon(refresh_icon) + + asset_box.setFixedHeight(25) + refresh_btn.setFixedWidth(25) + refresh_btn.setFixedHeight(25) + + asset_hlayout.addWidget(asset_label) + asset_hlayout.addWidget(asset_box) + asset_hlayout.addWidget(refresh_btn) + + # Options + options = QtWidgets.QHBoxLayout() + options.setAlignment(QtCore.Qt.AlignLeft) + + update_label = QtWidgets.QLabel("Update version") + update_check = QtWidgets.QCheckBox() + update_check.setChecked(False) + update_check.setToolTip("If checked it versions up the selected comp " + "file with else it will create a new slapcomp " + "file based on the selected comp file") + + current_comp_check = QtWidgets.QCheckBox() + current_comp_check.setChecked(True) + current_comp_label = QtWidgets.QLabel("Use current comp") + + options.addWidget(update_label) + options.addWidget(update_check) + + options.addWidget(current_comp_label) + options.addWidget(current_comp_check) + + accept_btn = QtWidgets.QPushButton("Switch") + + layout.addLayout(options) + layout.addLayout(comp_hlayout) + layout.addLayout(asset_hlayout) + layout.addWidget(accept_btn) + + self._open_from_dir = open_from_dir + self._comps = comp_box + self._assets = asset_box + self._update = update_check + self._use_current = current_comp_check + self._accept_btn = accept_btn + self._refresh_btn = refresh_btn + + self.setWindowTitle("Fusion Switch Shot") + self.setLayout(layout) + + self.resize(260, 140) + self.setMinimumWidth(260) + self.setFixedHeight(140) + + self.connections() + + # Update ui to correct state + self._on_use_current_comp() + self._refresh() + + def connections(self): + self._use_current.clicked.connect(self._on_use_current_comp) + self._open_from_dir.clicked.connect(self._on_open_from_dir) + self._refresh_btn.clicked.connect(self._refresh) + self._accept_btn.clicked.connect(self._on_switch) + + def _on_use_current_comp(self): + state = self._use_current.isChecked() + self._open_from_dir.setEnabled(not state) + self._comps.setEnabled(not state) + + def _on_open_from_dir(self): + + start_dir = self._get_context_directory() + comp_file, _ = QtWidgets.QFileDialog.getOpenFileName( + self, "Choose comp", start_dir) + + if not comp_file: + return + + # Create completer + self.populate_comp_box([comp_file]) + self._refresh() + + def _refresh(self): + # Clear any existing items + self._assets.clear() + + asset_names = [a["name"] for a in self.collect_assets()] + completer = QtWidgets.QCompleter(asset_names) + + self._assets.setCompleter(completer) + self._assets.addItems(asset_names) + + def _on_switch(self): + + if not self._use_current.isChecked(): + file_name = self._comps.itemData(self._comps.currentIndex()) + else: + comp = avalon.fusion.get_current_comp() + file_name = comp.GetAttrs("COMPS_FileName") + + asset = self._assets.currentText() + new = not self._update.isChecked() + + import colorbleed.scripts.fusion_switch_shot as switch_shot + switch_shot.switch(file_name, asset, new) + + def _get_context_directory(self): + + project = io.find_one({"type": "project", + "name": api.Session["AVALON_PROJECT"]}, + projection={"config": True}) + + template = project["config"]["template"]["work"] + dir = pipeline._format_work_template(template, api.Session) + + return dir + + def collect_slap_comps(self, directory): + items = glob.glob("{}/*.comp".format(directory)) + return items + + def collect_assets(self): + return list(io.find({"type": "asset", "silo": "film"})) + + def populate_comp_box(self, files): + """Ensure we display the filename only but the path is stored as well + + Args: + files (list): list of full file path [path/to/item/item.ext,] + + Returns: + None + """ + + for f in files: + filename = os.path.basename(f) + self._comps.addItem(filename, userData=f) + + +if __name__ == '__main__': + import sys + api.install(avalon.fusion) + + app = QtWidgets.QApplication(sys.argv) + window = App() + window.setStyleSheet(style.load_stylesheet()) + window.show() + sys.exit(app.exec_())