This commit is contained in:
wijnand 2018-03-12 16:20:30 +01:00
commit 428c96f21e
13 changed files with 703 additions and 34 deletions

40
colorbleed/fusion/lib.py Normal file
View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -28,3 +28,6 @@ class AbcLoader(colorbleed.maya.plugin.ReferenceLoader):
self[:] = nodes
return nodes
def switch(self, container, representation):
self.update(container, representation)

View file

@ -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)

View file

@ -35,3 +35,6 @@ class LookLoader(colorbleed.maya.plugin.ReferenceLoader):
returnNewNodes=True)
self[:] = nodes
def switch(self, container, representation):
self.update(container, representation)

View file

@ -28,3 +28,6 @@ class MayaAsciiLoader(colorbleed.maya.plugin.ReferenceLoader):
self[:] = nodes
return nodes
def switch(self, container, representation):
self.update(container, representation)

View file

@ -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)

View file

@ -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)

View file

@ -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):

View file

@ -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)

View file

@ -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)

View file

@ -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_())