diff --git a/colorbleed/fusion/lib.py b/colorbleed/fusion/lib.py index 7186cd2c..e1b81579 100644 --- a/colorbleed/fusion/lib.py +++ b/colorbleed/fusion/lib.py @@ -1,10 +1,15 @@ -import sys +import os +import re +import logging +import traceback import avalon.fusion +from avalon import api, io, pipeline +import colorbleed.lib as colorbleed -self = sys.modules[__name__] -self._project = None + +log = logging.getLogger(__name__) def update_frame_range(start, end, comp=None, set_render_range=True): @@ -38,3 +43,119 @@ def update_frame_range(start, end, comp=None, set_render_range=True): with avalon.fusion.comp_lock_and_undo_chunk(comp): comp.SetAttrs(attrs) + + +def get_next_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 + """ + + version_int = 1 + if os.path.isdir(folder): + re_version = re.compile("v\d+") + versions = [i for i in os.listdir(folder) if re_version.match(i) + and os.path.isdir(os.path.join(folder, i))] + if versions: + # ensure the "v" is not included and convert to ints + int_versions = [int(v[1:]) for v in versions] + version_int += max(int_versions) + + return "v{:03d}".format(version_int) + + +def update_savers(comp, session, project): + """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 + project (dict): the project document from the database + + Returns: + None + """ + + template = project["config"]["template"]["work"] + template_work = pipeline._format_work_template(template, session) + + render_dir = os.path.join(os.path.normpath(template_work), "renders") + version_folder = get_next_version_folder(render_dir) + renders_version = os.path.join(render_dir, version_folder) + + comp.Print("New renders to: %s\n" % render_dir) + + 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 switch(asset_name): + """Switch the current containers of the comp to the other asset (shot) + + Args: + asset_name (str): name of the asset (shot) + + Returns: + comp (PyObject): the comp instance + + """ + + assert asset_name, "Function requires asset name" + + host = api.registered_host() + assert host, "Host must be installed" + + # Get current project + 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 + + # Use the current open comp + current_comp = avalon.fusion.get_current_comp() + assert current_comp is not None, "Could not find current comp" + + 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) + log.debug(str(representation["_id"]) + "\n") + except Exception as e: + msg = traceback.format_stack(e) + log.debug("Error in switching! %s\n" % msg) + + log.info("Switched %i Loaders of the %i\n" % (len(representations), + len(containers))) + # Updating frame range + log.debug("Updating frame range ..\n") + 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) + + update_frame_range(start, end, comp=current_comp) + update_savers(current_comp, api.Session, project) + + return current_comp diff --git a/colorbleed/launcher_actions.py b/colorbleed/launcher_actions.py index 7d72cb2b..c5143e89 100644 --- a/colorbleed/launcher_actions.py +++ b/colorbleed/launcher_actions.py @@ -1,10 +1,13 @@ import os -from avalon import api, lib, pipeline + +from avalon import api, pipeline, lib + +import acre class FusionRenderNode(api.Action): - name = "fusionrendernode9" + name = "fusionrendernode" label = "F9 Render Node" icon = "object-group" order = 997 @@ -26,16 +29,17 @@ def process(self, session, **kwargs): """ - # Update environment with session - env = os.environ.copy() - env.update(session) + # Update environment with session and the current environment + tools_env = acre.get_tools(["global", "fusionnode9"]) + env = acre.compute(tools_env) + env = acre.merge(env, current_env=dict(os.environ)) # Get executable by name - app = lib.get_application(self.name) - env.update(app["environment"]) - executable = lib.which(app["executable"]) + executable = acre.which(self.name, env) + if not executable: + raise ValueError("Unable to find executable %s" % self.name) - return lib.launch(executable=executable, args=[], environment=env) + return acre.launch(executable=executable, args=[], environment=env) class VrayRenderSlave(api.Action): @@ -63,20 +67,16 @@ def process(self, session, **kwargs): """ # Update environment with session - env = os.environ.copy() - env.update(session) + tools_env = acre.get_tools(["global", "maya2018", "vrayrenderslave"]) + env = acre.compute(tools_env) + env = acre.merge(env, current_env=dict(os.environ)) # Get executable by name - app = lib.get_application(self.name) - env.update(app["environment"]) - executable = lib.which(app["executable"]) + executable = acre.which("vray", env) # Run as server - arguments = ["-server", "-portNumber=20207"] - - return lib.launch(executable=executable, - args=arguments, - environment=env) + args = ["-server", "-portNumber=20207"] + return acre.launch(executable=executable, args=args, environment=env) def register_launcher_actions(): diff --git a/colorbleed/plugins/fusion/publish/collect_comp.py b/colorbleed/plugins/fusion/publish/collect_comp.py index 1cf182c8..ed7166e6 100644 --- a/colorbleed/plugins/fusion/publish/collect_comp.py +++ b/colorbleed/plugins/fusion/publish/collect_comp.py @@ -1,5 +1,3 @@ -import os - import pyblish.api from avalon import fusion diff --git a/colorbleed/plugins/fusion/publish/publish_image_sequences.py b/colorbleed/plugins/fusion/publish/publish_image_sequences.py index 33d6d277..f60cf974 100644 --- a/colorbleed/plugins/fusion/publish/publish_image_sequences.py +++ b/colorbleed/plugins/fusion/publish/publish_image_sequences.py @@ -8,22 +8,19 @@ from colorbleed.action import get_errored_plugins_from_data -def _get_script(): +def _get_script_dir(): """Get path to the image sequence script""" - - # todo: use a more elegant way to get the python script - try: - from colorbleed.scripts import publish_filesequence - except Exception: - raise RuntimeError("Expected module 'publish_imagesequence'" - "to be available") + import colorbleed + config_dir = os.path.dirname(colorbleed.__file__) + script_dir = os.path.join(config_dir, "scripts") + except ImportError: + raise RuntimeError("This is a bug") - module_path = publish_filesequence.__file__ - if module_path.endswith(".pyc"): - module_path = module_path[:-len(".pyc")] + ".py" + assert os.path.isdir(script_dir), "Config is incomplete" + script_dir = script_dir.replace(os.sep, "/") - return module_path + return script_dir class PublishImageSequence(pyblish.api.InstancePlugin): @@ -73,7 +70,13 @@ def process(self, instance): startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE - process = subprocess.Popen(["python", _get_script(), + # Get script + script_dir = _get_script_dir() + script = os.path.join(script_dir, "publish_imagesequence.py") + assert os.path.isfile(script), ("Config incomplete, missing " + "`script/publish_imagesequence.py`") + + process = subprocess.Popen(["python", script, "--paths", path], bufsize=1, stdout=subprocess.PIPE, diff --git a/colorbleed/plugins/fusion/publish/submit_deadline.py b/colorbleed/plugins/fusion/publish/submit_deadline.py index 9fbf9b40..b82b20d4 100644 --- a/colorbleed/plugins/fusion/publish/submit_deadline.py +++ b/colorbleed/plugins/fusion/publish/submit_deadline.py @@ -39,7 +39,7 @@ def process(self, instance): # Collect all saver instances in context that are to be rendered saver_instances = [] - for instance in context[:]: + for instance in list(context): if not self.families[0] in instance.data.get("families"): # Allow only saver family instances continue @@ -117,22 +117,18 @@ def process(self, instance): payload["JobInfo"]["OutputFilename%d" % index] = filename # Include critical variables with submission - keys = [ - # TODO: This won't work if the slaves don't have accesss to - # these paths, such as if slaves are running Linux and the - # submitter is on Windows. - "PYTHONPATH", - "OFX_PLUGIN_PATH", - "FUSION9_MasterPrefs" - ] - environment = dict({key: os.environ[key] for key in keys - if key in os.environ}, **api.Session) + TOOL_ENV = os.getenv("TOOL_ENV") + assert TOOL_ENV, "No environment directory found" + + env = api.Session.copy() + env["AVALON_TOOLS"] = "global;fusion9" + env["TOOL_ENV"] = TOOL_ENV payload["JobInfo"].update({ "EnvironmentKeyValue%d" % index: "{key}={value}".format( key=key, - value=environment[key] - ) for index, key in enumerate(environment) + value=env[key] + ) for index, key in enumerate(env) }) self.log.info("Submitting..") diff --git a/colorbleed/plugins/fusion/publish/validate_unique_subsets.py b/colorbleed/plugins/fusion/publish/validate_unique_subsets.py index 527b4acc..bbea7cba 100644 --- a/colorbleed/plugins/fusion/publish/validate_unique_subsets.py +++ b/colorbleed/plugins/fusion/publish/validate_unique_subsets.py @@ -11,10 +11,9 @@ class ValidateUniqueSubsets(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): - context = instance.context subset = instance.data["subset"] - for other_instance in context[:]: + for other_instance in list(context): if other_instance == instance: continue @@ -26,4 +25,4 @@ def get_invalid(cls, instance): def process(self, instance): invalid = self.get_invalid(instance) if invalid: - raise RuntimeError("Animation content is invalid. See log.") + raise RuntimeError("Content is invalid. See log.") diff --git a/colorbleed/plugins/global/publish/submit_publish_job.py b/colorbleed/plugins/global/publish/submit_publish_job.py index c13f7e3d..2a0f268b 100644 --- a/colorbleed/plugins/global/publish/submit_publish_job.py +++ b/colorbleed/plugins/global/publish/submit_publish_job.py @@ -10,17 +10,15 @@ def _get_script(): """Get path to the image sequence script""" - try: - from colorbleed.scripts import publish_filesequence - except Exception as e: - raise RuntimeError("Expected module 'publish_imagesequence'" - "to be available") - module_path = publish_filesequence.__file__ - if module_path.endswith(".pyc"): - module_path = module_path[:-len(".pyc")] + ".py" + import colorbleed + configpath = os.path.dirname(colorbleed.__file__) + script = os.path.join(configpath, "scripts", "publish_filesequence.py") + # verify script exists + assert os.path.isfile(script), ("Script `publish_filesequence.py` does not" + "exists") - return module_path + return script class SubmitDependentImageSequenceJobDeadline(pyblish.api.InstancePlugin): @@ -70,7 +68,7 @@ def process(self, instance): "submission prior to this plug-in.") subset = instance.data["subset"] - state = instance.data.get("publishJobState", "Suspended") + state = instance.data.get("suspendPublishJob", "Active") job_name = "{batch} - {subset} [publish image sequence]".format( batch=job["Props"]["Name"], subset=subset @@ -144,7 +142,13 @@ def process(self, instance): # Transfer the environment from the original job to this dependent # job so they use the same environment + TOOL_ENV = os.getenv("TOOL_ENV") + assert TOOL_ENV, "No environment directory found" + environment = job["Props"].get("Env", {}) + environment["AVALON_TOOLS"] = "global;python36" + environment["TOOL_ENV"] = TOOL_ENV + payload["JobInfo"].update({ "EnvironmentKeyValue%d" % index: "{key}={value}".format( key=key, @@ -159,3 +163,7 @@ def process(self, instance): response = requests.post(url, json=payload) if not response.ok: raise Exception(response.text) + + # Temporary key name, deadlineSubmissionJob was already taken + if instance.data.get("runSlapComp", False): + instance.data["deadlineDependJob"] = response.json() diff --git a/colorbleed/plugins/maya/create/colorbleed_renderglobals.py b/colorbleed/plugins/maya/create/colorbleed_renderglobals.py index 3ca857a1..108f3d46 100644 --- a/colorbleed/plugins/maya/create/colorbleed_renderglobals.py +++ b/colorbleed/plugins/maya/create/colorbleed_renderglobals.py @@ -30,6 +30,8 @@ def __init__(self, *args, **kwargs): data["priority"] = 50 data["whitelist"] = False data["machineList"] = "" + data["runSlapComp"] = False + data["flowFile"] = "" self.data = data self.options = {"useSelection": False} # Force no content diff --git a/colorbleed/plugins/maya/publish/collect_renderlayers.py b/colorbleed/plugins/maya/publish/collect_renderlayers.py index f1495a0b..f6af4b87 100644 --- a/colorbleed/plugins/maya/publish/collect_renderlayers.py +++ b/colorbleed/plugins/maya/publish/collect_renderlayers.py @@ -120,10 +120,13 @@ def parse_options(self, render_globals): attributes = maya.read(render_globals) - options = {"renderGlobals": {}} - options["renderGlobals"]["Priority"] = attributes["priority"] - legacy = attributes["useLegacyRenderLayers"] - options["renderGlobals"]["UseLegacyRenderLayers"] = legacy + options = { + "renderGlobals": + { + "Priority": attributes["priority"], + "UseLegacyRenderLayers": attributes["useLegacyRenderLayers"] + } + } # Machine list machine_list = attributes["machineList"] @@ -135,4 +138,14 @@ def parse_options(self, render_globals): state = "Suspended" if attributes["suspendPublishJob"] else "Active" options["publishJobState"] = state + # Check if the run slap comp + if "runSlapComp" not in attributes: + self.log.warning("renderGlobals node might be out of data, missing" + "'runSlapComp' information.") + + if attributes.get("runSlapComp", False): + self.log.info("Running render through slap comp as post ..") + options["runSlapComp"] = True + options["flowFile"] = attributes["flowFile"] + return options diff --git a/colorbleed/plugins/maya/publish/submit_deadline.py b/colorbleed/plugins/maya/publish/submit_deadline.py index 6be16402..69c21025 100644 --- a/colorbleed/plugins/maya/publish/submit_deadline.py +++ b/colorbleed/plugins/maya/publish/submit_deadline.py @@ -194,36 +194,23 @@ def process(self, instance): "AuxFiles": [] } - # Include critical environment variables with submission - keys = [ - # This will trigger `userSetup.py` on the slave - # such that proper initialisation happens the same - # way as it does on a local machine. - # TODO(marcus): This won't work if the slaves don't - # have accesss to these paths, such as if slaves are - # running Linux and the submitter is on Windows. - "PYTHONPATH", - - # todo: This is a temporary fix for yeti variables - "PEREGRINEL_LICENSE", - "REDSHIFT_MAYAEXTENSIONSPATH", - "VRAY_FOR_MAYA2018_PLUGINS_X64", - "VRAY_PLUGINS_X64", - "VRAY_USE_THREAD_AFFINITY", - "MAYA_MODULE_PATH" - ] - environment = dict({key: os.environ[key] for key in keys - if key in os.environ}, **api.Session) - - PATHS = os.environ["PATH"].split(";") - environment["PATH"] = ";".join([p for p in PATHS - if p.startswith("P:")]) + # Collects tools setup + TOOL_ENV = os.getenv("TOOL_ENV") + assert TOOL_ENV, "No environment directory found" + AVALON_TOOLS = os.getenv("AVALON_TOOLS") + assert AVALON_TOOLS, "No environment setup found" + + env = api.Session.copy() + env["AVALON_TOOLS"] = AVALON_TOOLS + env["TOOL_ENV"] = TOOL_ENV + + # Ingest session in job environment payload["JobInfo"].update({ "EnvironmentKeyValue%d" % index: "{key}={value}".format( key=key, - value=environment[key] - ) for index, key in enumerate(environment) + value=env[key] + ) for index, key in enumerate(env) }) # Include optional render globals @@ -241,7 +228,7 @@ def process(self, instance): if not response.ok: raise Exception(response.text) - # Store output dir for unified publisher (filesequence) + # Store output dir for unified publisher (file sequence) instance.data["outputDir"] = os.path.dirname(output_filename_0) instance.data["deadlineSubmissionJob"] = response.json() @@ -254,7 +241,5 @@ def preflight_check(self, instance): if int(value) == value: continue - self.log.warning( - "%f=%d was rounded off to nearest integer" - % (value, int(value)) - ) + self.log.warning("%f=%d was rounded off to nearest integer" % + (value, int(value))) diff --git a/colorbleed/plugins/maya/publish/submit_switch_job.py b/colorbleed/plugins/maya/publish/submit_switch_job.py new file mode 100644 index 00000000..d16f543c --- /dev/null +++ b/colorbleed/plugins/maya/publish/submit_switch_job.py @@ -0,0 +1,140 @@ +import os + +from avalon import api +from avalon.vendor import requests + +import pyblish.api + + +def _get_script_dir(): + """Get path to the image sequence script""" + try: + import colorbleed + scriptdir = os.path.dirname(colorbleed.__file__) + fusion_scripts = os.path.join(scriptdir, "scripts", "fusion") + except: + raise RuntimeError("This is a bug") + + assert os.path.isdir(fusion_scripts), "Config is incomplete" + fusion_scripts = fusion_scripts.replace(os.sep, "/") + + return fusion_scripts + + +def _get_acre_path(): + + import acre + moduledir = os.path.dirname(acre.__file__) + if not os.path.isdir(moduledir): + raise ValueError("Could not find 'acre'") + + package_dir = os.path.abspath(os.path.join(moduledir, "..")) + + return package_dir + + +class SubmitDependentSwitchJobDeadline(pyblish.api.ContextPlugin): + """Run Switch Shot on specified comp as depending job + + """ + + label = "Submit Switch Jobs to Deadline" + order = pyblish.api.IntegratorOrder + 0.2 + hosts = ["maya"] + families = ["colorbleed.renderlayer"] + + def process(self, context): + + # Run it as depend on the last submitted instance + instance = context[-1] + + AVALON_DEADLINE = api.Session.get("AVALON_DEADLINE", + "http://localhost:8082") + assert AVALON_DEADLINE, "Requires AVALON_DEADLINE" + + job = instance.data.get("deadlineDependJob", None) + if not job: + self.log.warning("No dependent Job found") + return True + + filepath = instance.data("flowFile", "") + if not filepath: + raise RuntimeError("No flow file (comp) chosen") + + shot = api.Session["AVALON_ASSET"] + comment = instance.context.data["comment"] + + scriptdir = _get_script_dir() + scriptfile = os.path.join(scriptdir, "deadline_switch_and_submit.py") + + args = '--file_path "{}" --asset_name "{}" --render 1'.format( + filepath, shot) + payload_name = "{} SWITCH".format(os.path.basename(filepath)) + + payload = { + "JobInfo": { + "Plugin": "Python", + "BatchName": job["Props"]["Batch"], + "Name": payload_name, + "JobType": "Normal", + "JobDependency0": job["_id"], + "UserName": job["Props"]["User"], + "Comment": comment, + "InitialStatus": "Active"}, # Set job pending + "PluginInfo": { + "Version": "3.6", + "ScriptFile": scriptfile, + "Arguments": args, + "SingleFrameOnly": "True" + }, + "AuxFiles": [] + } + + machine_limit = self.get_machine_limit(instance) + payload["JobInfo"].update(machine_limit) + + TOOL_ENV = os.getenv("TOOL_ENV") + assert TOOL_ENV, "No environment directory found" + + environment = api.Session.copy() + environment["PYTHONPATH"] = _get_acre_path() + environment["AVALON_TOOLS"] = "global;python36" + environment["TOOL_ENV"] = TOOL_ENV + + payload["JobInfo"].update({ + "EnvironmentKeyValue%d" % index: "{key}={value}".format( + key=key, + value=environment[key] + ) for index, key in enumerate(environment) + }) + + url = "{}/api/jobs".format(AVALON_DEADLINE) + response = requests.post(url, json=payload) + if not response.ok: + raise Exception(response.text) + + # Temporary key name, deadlineSubmissionJob was already taken + if instance.data.get("runSlapComp", False): + instance.data["deadlineDependJob"] = response.json() + + self.log.info("Slap comp arguments: %s" % args) + + def get_machine_limit(self, instance): + """Retrieve the machine limit from the instance + + Args: + instance: instance to retrieve the renderglobals from + + Returns: + dict: {list type: list of machine names + + """ + + renderglobals = instance.data.get("renderGlobals", None) + if renderglobals is None: + return {} + + if "Whitelist" in renderglobals: + return {"Whitelist": renderglobals["Whitelist"]} + + return {"Blacklist": renderglobals.get("Blacklist")} diff --git a/colorbleed/scripts/GlobalJobPreLoad.py b/colorbleed/scripts/GlobalJobPreLoad.py new file mode 100644 index 00000000..538036a2 --- /dev/null +++ b/colorbleed/scripts/GlobalJobPreLoad.py @@ -0,0 +1,37 @@ +import os + +try: + import acre +except ImportError as exc: + raise ImportError("Module 'acre' cannot be found in the environment") + + +def __main__(deadlinePlugin): + deadlinePlugin.LogInfo("Setting up studio Environment!") + + # Compute + if "TOOL_ENV" not in os.environ: + print("Settings TOOL_ENV ..") + os.environ["TOOL_ENV"] = "P:/pipeline/dev/environments" + + tools = deadlinePlugin.GetProcessEnvironmentVariable("AVALON_TOOLS") + if not tools: + deadlinePlugin.LogInfo( + "Cannot set studio Environment without `AVALON_TOOLS`") + return + + deadlinePlugin.LogInfo("Setting environment for tools: %s" % tools) + + tools_env = acre.get_tools(tools.split(";")) + env = acre.compute(tools_env) + + # Get the merged environment for the local machine + merged = acre.merge(env, current_env=os.environ.copy()) + + # Keep only the changed variables + env = {key: value for key, value in merged.items() if key in env} + + # Update + for key, value in sorted(env.items()): + deadlinePlugin.LogInfo("\t%s: %s" % (key, value)) + deadlinePlugin.SetProcessEnvironmentVariable(key, value) diff --git a/colorbleed/scripts/__init__.py b/colorbleed/scripts/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/colorbleed/scripts/fusion/deadline_switch_and_submit.py b/colorbleed/scripts/fusion/deadline_switch_and_submit.py new file mode 100644 index 00000000..8e9e4e33 --- /dev/null +++ b/colorbleed/scripts/fusion/deadline_switch_and_submit.py @@ -0,0 +1,311 @@ +""" +This holds the logic to open a comp, switch the assets (shot), save it with +the correct naming. Optionally the new comp can be submitted to Deadline to +render and publish the output + +This module is for a standalone approach for Fusion similar to Maya. +Note that this will require FusionConsoleNode.exe and the BlackmagicFusion +module. + +Deadline runs a python process, lets call it P + +P will start the FusionConsoleNode in a new SUBPROCESS +This SUBPROCESS will need to have the same environment as P to ensure it can +use AVALON + + P --> SUBPROCESS (FusionConsoleNode.EXE /listen) + +From the SUBPROCESS comes a Fusion Console Node which will be used as the Fusion +instance to work in. In order to get the correct Fusion instance we use a +ScriptServer to get all Fusion programs which are running. +This is done by comparing the process ids with the subprocess.pid. + + See `get_fusion_instance` function for more details + +In `avalon.fusion.pipeline` we have create a work around to get the fusion +instance. This is done through + + getattr(sys.module["__main__"], "fusion", None) + +Because we do this we can also allow to set correct fusion module, this is done +by using the setattr. This will ensure that all other functions which are run +within `process()` can find `fusion`. + +""" + +import traceback +import logging +import time +import sys +import os + +try: + import acre +except ImportError: + raise ImportError("Module 'acre' cannot be found in the environment") + +log = logging.getLogger(__name__) + +# This script only works with Python 2.7 and 3.6 +version = "{0}{1}".format(*sys.version_info) # {major}{minor} +assert version in ["27", "36"], "Script only works in Python 2.7 or 3.6" +key = "FUSION_PYTHON{0}_HOME".format(version) + +# Importing BlackmagicFusion package in standalone Python interpreter +# crashes when not installed on default location but runs from, e.g. a +# network share. Forcing Fusion's Python home magically fixes it. +print("Setting %s to Python executable directory.." % key) +os.environ[key] = os.path.dirname(sys.executable) + +# Pipeline and config imports +from avalon import io, api, pipeline, lib +import avalon.fusion + +import colorbleed.lib as cblib +import colorbleed.fusion.lib as fusionlib + +# Application related imports +import BlackmagicFusion as bmf + + +def start_server(): + bmf.startserver() + return get_server() + + +def get_server(tries=10, timeout=0.5): + """Retrieve the server instance + Args: + tries (int): number of attempts + timeout (float): how long the function needs to time out in between + attempts + + Return: + server (PyObject) + """ + + count = 0 + srv = None + + while not srv: + count += 1 + print("Connecting to ScriptServer (try: %s)" % count) + srv = bmf.scriptapp("", "localhost", timeout) # Runs script server + if count > tries: + break + + return srv + + +def get_fusion_instance(pid, srv, timeout=10): + """Get the fusion instance which has been launched + + Args: + pid (int, long): process id + srv (PyObject): fusion server instance + timeout (float): how long the function needs to time out in between + attempts + + Returns: + fusion + + """ + + count = 0 + host = None + while not host: + if count > timeout: + break + fusion_hosts = srv.GetHostList().values() + host = next((i for i in fusion_hosts if int(i["ProcessID"]) == pid), + None) + if not host: + print("Find Fusion host... (%ss)" % count) + time.sleep(0.5) + count += 0.5 + + assert host, "Fusion not found with pid: %s" % pid + + return bmf.scriptapp(host["Name"], "localhost", 2, host["UUID"]) + + +def create_new_filepath(session): + """ + Create a new file path based on the session and the project's template + + Args: + session (dict): a copy of the Avalon session + + Returns: + file path (str) + + """ + + # Save updated slap comp + project = io.find_one({"type": "project", + "name": session["AVALON_PROJECT"]}) + + template = project["config"]["template"]["work"] + template_work = pipeline._format_work_template(template, session) + + comp_dir = os.path.abspath(os.path.join(template_work, "scenes")) + + # Ensure destination exists + if not os.path.isdir(comp_dir): + log.warning("Folder did not exist, creating folder structure") + os.makedirs(comp_dir) + + # Compute output path + new_filename = "{}_{}_v001.comp".format(session["AVALON_PROJECT"], + session["AVALON_ASSET"]) + new_filepath = os.path.join(comp_dir, new_filename) + + # Create new unique file path + if os.path.exists(new_filepath): + new_filepath = cblib.version_up(new_filepath) + + return new_filepath + + +def submit(current_comp, source=""): + """Set render mode to deadline and publish / submit comp + + Args: + source (str): file path + current_comp (PyObject): current comp instance + + Returns: + bool (True) + + """ + import pyblish + from pyblish import util + + # Set comp render mode to deadline + current_comp.SetData("colorbleed.rendermode", "deadline") + + error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}\n" + comment = "slapped using: {}".format(os.path.basename(source)) + + # Publish + context = pyblish.api.Context() + context.data["comment"] = comment + + results = util.publish(context=context) + if not results: + raise RuntimeError("Nothing collected for publish") + + # Collect errors, {plugin name: error}, if any + error_results = [r for r in results.data["results"] if r["error"]] + if error_results: + custom_traceback = "" + for result in error_results: + custom_traceback += error_format.format(**result) + raise RuntimeError("Errors occured:\n%s" % custom_traceback) + + return True + + +def process(file_path, asset_name, deadline=False): + """Run switch in a Fusion Console Node (cmd) + + Open the comp (file_path) and switch to the asset (asset_name) and when + deadline is enabled it will submit the switched comp to Deadline to render + and publish the output. + + Args: + file_path (str): File path of the comp to use + asset_name (str): Name of the asset (shot) to switch + deadline (bool, optional): If set True the new composition file will be + used to render + Returns: + None + + """ + + # Start a fusion console node in "listen" mode + tools_env = acre.get_tools(["global", "fusionnode9"]) + env = acre.compute(tools_env) + env = acre.merge(env, current_env=dict(os.environ)) + + # Search for the executable within the tool's environment + # by temporarily taking on its `PATH` settings + exe = acre.which("fusionconsolenode", env) + proc = acre.launch(exe, environment=env, args=["/listen"]) + + srv = get_server() + if not srv: + print("No server found, starting server ..") + srv = start_server() + + print("Found server: %s" % srv) + + # Force fusion into main magical module so that host.ls() works + fusion = get_fusion_instance(proc.pid, srv) + assert fusion + setattr(sys.modules["__main__"], "fusion", fusion) + + print("Connected to: %s" % fusion) + + api.install(avalon.fusion) + from avalon.fusion import pipeline + + # Build the session to switch to + api.update_current_task(task="comp", asset=asset_name, app="fusion") + + # This does not set + loaded_comp = fusion.LoadComp(file_path) + if not loaded_comp: + raise RuntimeError("Comp could not be loaded. File '%s'" % file_path) + + pipeline.set_current_comp(loaded_comp) + current_comp = pipeline.get_current_comp() + + assert loaded_comp == current_comp, "Could not find the correct comp" + print("Loaded comp name: %s" % current_comp.GetAttrs("COMPS_FileName")) + + try: + # Execute script in comp + fusionlib.switch(asset_name=asset_name) + new_file_path = create_new_filepath(api.Session) + print("Saving comp as: %s" % new_file_path) + current_comp.Save(new_file_path) + if deadline: + submit(current_comp, source=file_path) + except Exception: + print(traceback.format_exc()) # ensure detailed traceback + raise + finally: + pipeline.set_current_comp(None) + print("Closing running process ..") + proc.terminate() # Ensure process closes when failing + + +# Usability for deadline job submission +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") + + parser.add_argument("--render", + default=False, + help="If set True the new composition file will be used" + "to render") + + args = parser.parse_args() + + process(file_path=args.file_path, + asset_name=args.asset_name, + deadline=args.render) diff --git a/colorbleed/scripts/fusion_switch_shot.py b/colorbleed/scripts/fusion_switch_shot.py deleted file mode 100644 index 07bbafa0..00000000 --- a/colorbleed/scripts/fusion_switch_shot.py +++ /dev/null @@ -1,244 +0,0 @@ -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 - """ - - new_version = 1 - if os.path.isdir(folder): - re_version = re.compile("v\d+$") - versions = [i for i in os.listdir(folder) if os.path.isdir(i) - and re_version.match(i)] - if versions: - # ensure the "v" is not included - new_version = int(max(versions)[1:]) + 1 - - 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(asset_name, filepath=None, 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 - if not filepath: - current_comp = avalon.fusion.get_current_comp() - assert current_comp is not None, "Could not find current comp" - else: - fusion = _get_fusion_instance() - current_comp = fusion.LoadComp(filepath, quiet=True) - 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.asset_name, args.file_path) - - sys.exit(0) diff --git a/colorbleed/scripts/publish_filesequence.py b/colorbleed/scripts/publish_filesequence.py index c37ceee0..f026cfc4 100644 --- a/colorbleed/scripts/publish_filesequence.py +++ b/colorbleed/scripts/publish_filesequence.py @@ -62,8 +62,9 @@ def publish(paths, gui=False): sys.exit(2) -def __main__(): +if __name__ == '__main__': import argparse + parser = argparse.ArgumentParser() parser.add_argument("--paths", nargs="*", @@ -81,7 +82,3 @@ def __main__(): print("Running publish imagesequence...") print("Paths: {}".format(kwargs.paths or [os.getcwd()])) publish(kwargs.paths, gui=kwargs.gui) - - -if __name__ == '__main__': - __main__() diff --git a/setup/fusion/scripts/Comp/colorbleed/switch_ui.py b/setup/fusion/scripts/Comp/colorbleed/switch_ui.py index 8f1466ab..2d060abb 100644 --- a/setup/fusion/scripts/Comp/colorbleed/switch_ui.py +++ b/setup/fusion/scripts/Comp/colorbleed/switch_ui.py @@ -2,14 +2,16 @@ import glob import logging -import avalon.io as io -import avalon.api as api -import avalon.pipeline as pipeline +from avalon import io, api, pipeline import avalon.fusion + import avalon.style as style from avalon.vendor.Qt import QtWidgets, QtCore from avalon.vendor import qtawesome as qta +import colorbleed.lib as cblib +import colorbleed.fusion.lib as flib + log = logging.getLogger("Fusion Switch Shot") @@ -125,7 +127,7 @@ def _on_open_from_dir(self): start_dir = self._get_context_directory() comp_file, _ = QtWidgets.QFileDialog.getOpenFileName( - self, "Choose comp", start_dir) + self, "Choose comp", start_dir, "*.comp") if not comp_file: return @@ -146,16 +148,22 @@ def _refresh(self): def _on_switch(self): + asset = self._assets.currentText() + _comp = avalon.fusion.get_current_comp() if not self._use_current.isChecked(): file_name = self._comps.itemData(self._comps.currentIndex()) + # Get the current comp's App (FusionUI) + _fusion = _comp.GetApp() + # Open the selected comp + loaded_comp = _fusion.LoadComp(file_name) + if not loaded_comp: + raise RuntimeError("Invalid file: '%s'" % file_name) else: - comp = avalon.fusion.get_current_comp() - file_name = comp.GetAttrs("COMPS_FileName") - - asset = self._assets.currentText() + file_name = _comp.GetAttrs("COMPS_FileName") - import colorbleed.scripts.fusion_switch_shot as switch_shot - switch_shot.switch(asset_name=asset, filepath=file_name, new=True) + switched_comp = flib.switch(asset) + new_filename = cblib.version_up(file_name) + switched_comp.Save(new_filename) def _get_context_directory(self):