diff --git a/pype/hosts/tvpaint/__init__.py b/pype/hosts/tvpaint/__init__.py index cb02c87151..7027f0fb55 100644 --- a/pype/hosts/tvpaint/__init__.py +++ b/pype/hosts/tvpaint/__init__.py @@ -2,6 +2,7 @@ import os import logging from avalon.tvpaint.communication_server import register_localization_file +from avalon.tvpaint import pipeline import avalon.api import pyblish.api from pype import PLUGINS_DIR @@ -13,6 +14,23 @@ LOAD_PATH = os.path.join(PLUGINS_DIR, "tvpaint", "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "tvpaint", "create") +def on_instance_toggle(instance, old_value, new_value): + instance_id = instance.data["uuid"] + found_idx = None + current_instances = pipeline.list_instances() + for idx, workfile_instance in enumerate(current_instances): + if workfile_instance["uuid"] == instance_id: + found_idx = idx + break + + if found_idx is None: + return + + if "active" in current_instances[found_idx]: + current_instances[found_idx]["active"] = new_value + pipeline._write_instances(current_instances) + + def install(): log.info("Pype - Installing TVPaint integration") current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -23,6 +41,12 @@ def install(): avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + registered_callbacks = ( + pyblish.api.registered_callbacks().get("instanceToggled") or [] + ) + if on_instance_toggle not in registered_callbacks: + pyblish.api.register_callback("instanceToggled", on_instance_toggle) + def uninstall(): log.info("Pype - Uninstalling TVPaint integration") diff --git a/pype/plugins/global/publish/extract_review.py b/pype/plugins/global/publish/extract_review.py index 34fe6f5e10..dd28e6fec3 100644 --- a/pype/plugins/global/publish/extract_review.py +++ b/pype/plugins/global/publish/extract_review.py @@ -30,7 +30,8 @@ class ExtractReview(pyblish.api.InstancePlugin): "premiere", "harmony", "standalonepublisher", - "fusion" + "fusion", + "tvpaint" ] # Supported extensions diff --git a/pype/plugins/tvpaint/create/create_render_layer.py b/pype/plugins/tvpaint/create/create_render_layer.py new file mode 100644 index 0000000000..c2921cebbe --- /dev/null +++ b/pype/plugins/tvpaint/create/create_render_layer.py @@ -0,0 +1,150 @@ +from avalon.tvpaint import pipeline, lib + + +class CreateRenderlayer(pipeline.Creator): + """Mark layer group as one instance.""" + name = "render_layer" + label = "RenderLayer" + family = "renderLayer" + icon = "cube" + defaults = ["Main"] + + rename_group = True + + subset_template = "{family}_{name}" + rename_script_template = ( + "tv_layercolor \"setcolor\"" + " {clip_id} {group_id} {r} {g} {b} \"{name}\"" + ) + + def process(self): + self.log.debug("Query data from workfile.") + instances = pipeline.list_instances() + layers_data = lib.layers_data() + + self.log.debug("Checking for selection groups.") + # Collect group ids from selection + group_ids = set() + for layer in layers_data: + if layer["selected"]: + group_ids.add(layer["group_id"]) + + # Raise if there is no selection + if not group_ids: + raise AssertionError("Nothing is selected.") + + # This creator should run only on one group + if len(group_ids) > 1: + raise AssertionError("More than one group is in selection.") + + group_id = tuple(group_ids)[0] + # If group id is `0` it is `default` group which is invalid + if group_id == 0: + raise AssertionError( + "Selection is not in group. Can't mark selection as Beauty." + ) + + self.log.debug(f"Selected group id is \"{group_id}\".") + self.data["group_id"] = group_id + + family = self.data["family"] + # Extract entered name + name = self.data["subset"][len(family):] + self.log.info(f"Extracted name from subset name \"{name}\".") + self.data["name"] = name + + # Change subset name by template + subset_name = self.subset_template.format(**{ + "family": self.family, + "name": name + }) + self.log.info(f"New subset name \"{subset_name}\".") + self.data["subset"] = subset_name + + # Check for instances of same group + existing_instance = None + existing_instance_idx = None + # Check if subset name is not already taken + same_subset_instance = None + same_subset_instance_idx = None + for idx, instance in enumerate(instances): + if instance["family"] == family: + if instance["group_id"] == group_id: + existing_instance = instance + existing_instance_idx = idx + elif instance["subset"] == subset_name: + same_subset_instance = instance + same_subset_instance_idx = idx + + if ( + same_subset_instance_idx is not None + and existing_instance_idx is not None + ): + break + + if same_subset_instance_idx is not None: + if self._ask_user_subset_override(same_subset_instance): + instances.pop(same_subset_instance_idx) + else: + return + + if existing_instance is not None: + self.log.info( + f"Beauty instance for group id {group_id} already exists" + ", overriding" + ) + instances[existing_instance_idx] = self.data + else: + instances.append(self.data) + + self.write_instances(instances) + + if not self.rename_group: + self.log.info("Group rename function is turned off. Skipping") + return + + self.log.debug("Querying groups data from workfile.") + groups_data = lib.groups_data() + + self.log.debug("Changing name of the group.") + selected_group = None + for group_data in groups_data: + if group_data["group_id"] == group_id: + selected_group = group_data + + # Rename TVPaint group (keep color same) + # - groups can't contain spaces + new_group_name = name.replace(" ", "_") + rename_script = self.rename_script_template.format( + clip_id=selected_group["clip_id"], + group_id=selected_group["group_id"], + r=selected_group["red"], + g=selected_group["green"], + b=selected_group["blue"], + name=new_group_name + ) + lib.execute_george_through_file(rename_script) + + self.log.info( + f"Name of group with index {group_id}" + f" was changed to \"{new_group_name}\"." + ) + + def _ask_user_subset_override(self, instance): + from Qt.QtWidgets import QMessageBox + + title = "Subset \"{}\" already exist".format(instance["subset"]) + text = ( + "Instance with subset name \"{}\" already exists." + "\n\nDo you want to override existing?" + ).format(instance["subset"]) + + dialog = QMessageBox() + dialog.setWindowTitle(title) + dialog.setText(text) + dialog.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + dialog.setDefaultButton(QMessageBox.Yes) + dialog.exec_() + if dialog.result() == QMessageBox.Yes: + return True + return False diff --git a/pype/plugins/tvpaint/create/create_render_pass.py b/pype/plugins/tvpaint/create/create_render_pass.py new file mode 100644 index 0000000000..585da6ec00 --- /dev/null +++ b/pype/plugins/tvpaint/create/create_render_pass.py @@ -0,0 +1,105 @@ +from avalon.tvpaint import pipeline, lib + + +class CreateRenderPass(pipeline.Creator): + """Render pass is combination of one or more layers from same group. + + Requirement to create Render Pass is to have already created beauty + instance. Beauty instance is used as base for subset name. + """ + name = "render_pass" + label = "RenderPass" + family = "renderPass" + icon = "cube" + defaults = ["Main"] + + subset_template = "{family}_{render_layer}_{pass}" + + def process(self): + self.log.debug("Query data from workfile.") + instances = pipeline.list_instances() + layers_data = lib.layers_data() + + self.log.debug("Checking selection.") + # Get all selected layers and their group ids + group_ids = set() + selected_layers = [] + for layer in layers_data: + if layer["selected"]: + selected_layers.append(layer) + group_ids.add(layer["group_id"]) + + # Raise if nothing is selected + if not selected_layers: + raise AssertionError("Nothing is selected.") + + # Raise if layers from multiple groups are selected + if len(group_ids) != 1: + raise AssertionError("More than one group is in selection.") + + group_id = tuple(group_ids)[0] + self.log.debug(f"Selected group id is \"{group_id}\".") + + # Find beauty instance for selected layers + beauty_instance = None + for instance in instances: + if ( + instance["family"] == "renderLayer" + and instance["group_id"] == group_id + ): + beauty_instance = instance + break + + # Beauty is required for this creator so raise if was not found + if beauty_instance is None: + raise AssertionError("Beauty pass does not exist yet.") + + render_layer = beauty_instance["name"] + + # Extract entered name + family = self.data["family"] + name = self.data["subset"] + # Is this right way how to get name? + name = name[len(family):] + self.log.info(f"Extracted name from subset name \"{name}\".") + + self.data["group_id"] = group_id + self.data["pass"] = name + self.data["render_layer"] = render_layer + + # Collect selected layer ids to be stored into instance + layer_ids = [layer["layer_id"] for layer in selected_layers] + self.data["layer_ids"] = layer_ids + + # Replace `beauty` in beauty's subset name with entered name + subset_name = self.subset_template.format(**{ + "family": family, + "render_layer": render_layer, + "pass": name + }) + self.data["subset"] = subset_name + self.log.info(f"New subset name is \"{subset_name}\".") + + # Check if same instance already exists + existing_instance = None + existing_instance_idx = None + for idx, instance in enumerate(instances): + if ( + instance["family"] == family + and instance["group_id"] == group_id + and instance["pass"] == name + ): + existing_instance = instance + existing_instance_idx = idx + break + + if existing_instance is not None: + self.log.info( + f"Render pass instance for group id {group_id}" + f" and name \"{name}\" already exists, overriding." + ) + instances[existing_instance_idx] = self.data + else: + instances.append(self.data) + + self.write_instances(instances) diff --git a/pype/plugins/tvpaint/create/create_review.py b/pype/plugins/tvpaint/create/create_review.py new file mode 100644 index 0000000000..9f7ee1396e --- /dev/null +++ b/pype/plugins/tvpaint/create/create_review.py @@ -0,0 +1,18 @@ +from avalon.tvpaint import pipeline + + +class CreateReview(pipeline.Creator): + """Review for global review of all layers.""" + name = "review" + label = "Review" + family = "review" + icon = "cube" + defaults = ["Main"] + + def process(self): + instances = pipeline.list_instances() + for instance in instances: + if instance["family"] == self.family: + self.log.info("Review family is already Created.") + return + super(CreateReview, self).process() diff --git a/pype/plugins/tvpaint/load/load_image.py b/pype/plugins/tvpaint/load/load_image.py index 0d5a0b4aa1..f77fab87f8 100644 --- a/pype/plugins/tvpaint/load/load_image.py +++ b/pype/plugins/tvpaint/load/load_image.py @@ -1,9 +1,8 @@ -from avalon import api from avalon.vendor import qargparse -from avalon.tvpaint import CommunicatorWrapper +from avalon.tvpaint import lib, pipeline -class ImportImage(api.Loader): +class ImportImage(pipeline.Loader): """Load image or image sequence to TVPaint as new layer.""" families = ["render", "image", "background", "plate"] @@ -80,4 +79,4 @@ class ImportImage(api.Loader): layer_name, load_options_str ) - return CommunicatorWrapper.execute_george_through_file(george_script) + return lib.execute_george_through_file(george_script) diff --git a/pype/plugins/tvpaint/load/load_reference_image.py b/pype/plugins/tvpaint/load/load_reference_image.py new file mode 100644 index 0000000000..0fa4cefc51 --- /dev/null +++ b/pype/plugins/tvpaint/load/load_reference_image.py @@ -0,0 +1,244 @@ +from avalon.pipeline import get_representation_context +from avalon.vendor import qargparse +from avalon.tvpaint import lib, pipeline + + +class LoadImage(pipeline.Loader): + """Load image or image sequence to TVPaint as new layer.""" + + families = ["render", "image", "background", "plate"] + representations = ["*"] + + label = "Load Image" + order = 1 + icon = "image" + color = "white" + + import_script = ( + "filepath = \"{}\"\n" + "layer_name = \"{}\"\n" + "tv_loadsequence filepath {}PARSE layer_id\n" + "tv_layerrename layer_id layer_name" + ) + + defaults = { + "stretch": True, + "timestretch": True, + "preload": True + } + + options = [ + qargparse.Boolean( + "stretch", + label="Stretch to project size", + default=True, + help="Stretch loaded image/s to project resolution?" + ), + qargparse.Boolean( + "timestretch", + label="Stretch to timeline length", + default=True, + help="Clip loaded image/s to timeline length?" + ), + qargparse.Boolean( + "preload", + label="Preload loaded image/s", + default=True, + help="Preload image/s?" + ) + ] + + def load(self, context, name, namespace, options): + stretch = options.get("stretch", self.defaults["stretch"]) + timestretch = options.get("timestretch", self.defaults["timestretch"]) + preload = options.get("preload", self.defaults["preload"]) + + load_options = [] + if stretch: + load_options.append("\"STRETCH\"") + if timestretch: + load_options.append("\"TIMESTRETCH\"") + if preload: + load_options.append("\"PRELOAD\"") + + load_options_str = "" + for load_option in load_options: + load_options_str += (load_option + " ") + + # Prepare layer name + asset_name = context["asset"]["name"] + subset_name = context["subset"]["name"] + layer_name = self.get_unique_layer_name(asset_name, subset_name) + + # Fill import script with filename and layer name + # - filename mus not contain backwards slashes + george_script = self.import_script.format( + self.fname.replace("\\", "/"), + layer_name, + load_options_str + ) + + lib.execute_george_through_file(george_script) + + loaded_layer = None + layers = lib.layers_data() + for layer in layers: + if layer["name"] == layer_name: + loaded_layer = layer + break + + if loaded_layer is None: + raise AssertionError( + "Loading probably failed during execution of george script." + ) + + layer_ids = [loaded_layer["layer_id"]] + namespace = namespace or layer_name + return pipeline.containerise( + name=name, + namespace=namespace, + layer_ids=layer_ids, + context=context, + loader=self.__class__.__name__ + ) + + def _remove_layers(self, layer_ids, layers=None): + if not layer_ids: + return + + if layers is None: + layers = lib.layers_data() + + available_ids = set(layer["layer_id"] for layer in layers) + layer_ids_to_remove = [] + + for layer_id in layer_ids: + if layer_id in available_ids: + layer_ids_to_remove.append(layer_id) + + if not layer_ids_to_remove: + return + + george_script_lines = [] + for layer_id in layer_ids_to_remove: + line = "tv_layerkill {}".format(layer_id) + george_script_lines.append(line) + george_script = "\n".join(george_script_lines) + lib.execute_george_through_file(george_script) + + def remove(self, container): + layer_ids = self.layer_ids_from_container(container) + self._remove_layers(layer_ids) + + current_containers = pipeline.ls() + pop_idx = None + for idx, cur_con in enumerate(current_containers): + if cur_con["objectName"] == container["objectName"]: + pop_idx = idx + break + + if pop_idx is None: + self.log.warning( + "Didn't found container in workfile containers. {}".format( + container + ) + ) + return + + current_containers.pop(pop_idx) + pipeline.write_workfile_metadata( + pipeline.SECTION_NAME_CONTAINERS, current_containers + ) + + def switch(self, container, representation): + self.update(container, representation) + + def update(self, container, representation): + """Replace container with different version. + + New layers are loaded as first step. Then is tried to change data in + new layers with data from old layers. When that is done old layers are + removed. + """ + # Create new containers first + context = get_representation_context(representation) + name = container["name"] + namespace = container["namespace"] + new_container = self.load(context, name, namespace, {}) + new_layer_ids = self.layer_ids_from_container(new_container) + + # Get layer ids from previous container + old_layer_ids = self.layer_ids_from_container(container) + + layers = lib.layers_data() + layers_by_id = { + layer["layer_id"]: layer + for layer in layers + } + + old_layers = [] + new_layers = [] + for layer_id in old_layer_ids: + layer = layers_by_id.get(layer_id) + if layer: + old_layers.append(layer) + + for layer_id in new_layer_ids: + layer = layers_by_id.get(layer_id) + if layer: + new_layers.append(layer) + + # Prepare few data + new_start_position = None + new_group_id = None + for layer in old_layers: + position = layer["position"] + group_id = layer["group_id"] + if new_start_position is None: + new_start_position = position + elif new_start_position > position: + new_start_position = position + + if new_group_id is None: + new_group_id = group_id + elif new_group_id < 0: + continue + elif new_group_id != group_id: + new_group_id = -1 + + george_script_lines = [] + # Group new layers to same group as previous container layers had + # - all old layers must be under same group + if new_group_id is not None and new_group_id > 0: + for layer in new_layers: + line = "tv_layercolor \"set\" {} {}".format( + layer["layer_id"], new_group_id + ) + george_script_lines.append(line) + + # Rename new layer to have same name + # - only if both old and new have one layer + if len(old_layers) == 1 and len(new_layers) == 1: + layer_name = old_layers[0]["name"] + george_script_lines.append( + "tv_layerrename {} \"{}\"".format( + new_layers[0]["layer_id"], layer_name + ) + ) + + # Change position of new layer + # - this must be done before remove old layers + if len(new_layers) == 1 and new_start_position is not None: + new_layer = new_layers[0] + george_script_lines.extend([ + "tv_layerset {}".format(new_layer["layer_id"]), + "tv_layermove {}".format(new_start_position) + ]) + + # Execute george scripts if there are any + if george_script_lines: + george_script = "\n".join(george_script_lines) + lib.execute_george_through_file(george_script) + + # Remove old container + self.remove(container) diff --git a/pype/plugins/tvpaint/publish/collect_instances.py b/pype/plugins/tvpaint/publish/collect_instances.py new file mode 100644 index 0000000000..a9c19f9c0a --- /dev/null +++ b/pype/plugins/tvpaint/publish/collect_instances.py @@ -0,0 +1,172 @@ +import json +import copy +import pyblish.api +from avalon import io + + +class CollectInstances(pyblish.api.ContextPlugin): + label = "Collect Instances" + order = pyblish.api.CollectorOrder - 1 + hosts = ["tvpaint"] + + def process(self, context): + workfile_instances = context.data["workfileInstances"] + + self.log.debug("Collected ({}) instances:\n{}".format( + len(workfile_instances), + json.dumps(workfile_instances, indent=4) + )) + + for instance_data in workfile_instances: + instance_data["fps"] = context.data["fps"] + + # Store workfile instance data to instance data + instance_data["originData"] = copy.deepcopy(instance_data) + # Global instance data modifications + # Fill families + family = instance_data["family"] + # Add `review` family for thumbnail integration + instance_data["families"] = [family, "review"] + + # Instance name + subset_name = instance_data["subset"] + name = instance_data.get("name", subset_name) + instance_data["name"] = name + + active = instance_data.get("active", True) + instance_data["active"] = active + instance_data["publish"] = active + # Add representations key + instance_data["representations"] = [] + + # Different instance creation based on family + instance = None + if family == "review": + # Change subset name + task_name = io.Session["AVALON_TASK"] + new_subset_name = "{}{}".format(family, task_name.capitalize()) + instance_data["subset"] = new_subset_name + + instance = context.create_instance(**instance_data) + instance.data["layers"] = context.data["layersData"] + # Add ftrack family + instance.data["families"].append("ftrack") + + elif family == "renderLayer": + instance = self.create_render_layer_instance( + context, instance_data + ) + elif family == "renderPass": + instance = self.create_render_pass_instance( + context, instance_data + ) + else: + raise AssertionError( + "Instance with unknown family \"{}\": {}".format( + family, instance_data + ) + ) + + frame_start = context.data["frameStart"] + frame_end = frame_start + for layer in instance.data["layers"]: + _frame_end = layer["frame_end"] + if _frame_end > frame_end: + frame_end = _frame_end + + instance.data["frameStart"] = frame_start + instance.data["frameEnd"] = frame_end + + self.log.debug("Created instance: {}\n{}".format( + instance, json.dumps(instance.data, indent=4) + )) + + def create_render_layer_instance(self, context, instance_data): + name = instance_data["name"] + # Change label + subset_name = instance_data["subset"] + instance_data["label"] = "{}_Beauty".format(name) + + # Change subset name + # Final family of an instance will be `render` + new_family = "render" + task_name = io.Session["AVALON_TASK"] + new_subset_name = "{}{}_{}_Beauty".format( + new_family, task_name.capitalize(), name + ) + instance_data["subset"] = new_subset_name + self.log.debug("Changed subset name \"{}\"->\"{}\"".format( + subset_name, new_subset_name + )) + + # Get all layers for the layer + layers_data = context.data["layersData"] + group_id = instance_data["group_id"] + group_layers = [] + for layer in layers_data: + if layer["group_id"] == group_id and layer["visible"]: + group_layers.append(layer) + + if not group_layers: + # Should be handled here? + self.log.warning(( + f"Group with id {group_id} does not contain any layers." + f" Instance \"{name}\" not created." + )) + return None + + instance_data["layers"] = group_layers + + # Add ftrack family + instance_data["families"].append("ftrack") + + return context.create_instance(**instance_data) + + def create_render_pass_instance(self, context, instance_data): + pass_name = instance_data["pass"] + self.log.info( + "Creating render pass instance. \"{}\"".format(pass_name) + ) + # Change label + render_layer = instance_data["render_layer"] + instance_data["label"] = "{}_{}".format(render_layer, pass_name) + + # Change subset name + # Final family of an instance will be `render` + new_family = "render" + old_subset_name = instance_data["subset"] + task_name = io.Session["AVALON_TASK"] + new_subset_name = "{}{}_{}_{}".format( + new_family, task_name.capitalize(), render_layer, pass_name + ) + instance_data["subset"] = new_subset_name + self.log.debug("Changed subset name \"{}\"->\"{}\"".format( + old_subset_name, new_subset_name + )) + + layers_data = context.data["layersData"] + layers_by_id = { + layer["layer_id"]: layer + for layer in layers_data + } + + layer_ids = instance_data["layer_ids"] + render_pass_layers = [] + for layer_id in layer_ids: + layer = layers_by_id.get(layer_id) + if not layer: + self.log.warning(f"Layer with id {layer_id} was not found.") + continue + + render_pass_layers.append(layer) + + if not render_pass_layers: + name = instance_data["name"] + self.log.warning( + f"None of the layers from the RenderPass \"{name}\"" + " exist anymore. Instance not created." + ) + return None + + instance_data["layers"] = render_pass_layers + return context.create_instance(**instance_data) diff --git a/pype/plugins/tvpaint/publish/collect_workfile_data.py b/pype/plugins/tvpaint/publish/collect_workfile_data.py new file mode 100644 index 0000000000..31fd97ced4 --- /dev/null +++ b/pype/plugins/tvpaint/publish/collect_workfile_data.py @@ -0,0 +1,66 @@ +import json + +import pyblish.api +from avalon.tvpaint import pipeline, lib + + +class CollectWorkfileData(pyblish.api.ContextPlugin): + label = "Collect Workfile Data" + order = pyblish.api.CollectorOrder - 1.01 + hosts = ["tvpaint"] + + def process(self, context): + self.log.info("Collecting instance data from workfile") + instance_data = pipeline.list_instances() + self.log.debug( + "Instance data:\"{}".format(json.dumps(instance_data, indent=4)) + ) + context.data["workfileInstances"] = instance_data + + self.log.info("Collecting layers data from workfile") + layers_data = lib.layers_data() + self.log.debug( + "Layers data:\"{}".format(json.dumps(layers_data, indent=4)) + ) + context.data["layersData"] = layers_data + + self.log.info("Collecting groups data from workfile") + group_data = lib.groups_data() + self.log.debug( + "Group data:\"{}".format(json.dumps(group_data, indent=4)) + ) + context.data["groupsData"] = group_data + + self.log.info("Collecting scene data from workfile") + workfile_info_parts = lib.execute_george("tv_projectinfo").split(" ") + + frame_start = int(workfile_info_parts.pop(-1)) + field_order = workfile_info_parts.pop(-1) + frame_rate = float(workfile_info_parts.pop(-1)) + pixel_apsect = float(workfile_info_parts.pop(-1)) + height = int(workfile_info_parts.pop(-1)) + width = int(workfile_info_parts.pop(-1)) + workfile_path = " ".join(workfile_info_parts).replace("\"", "") + + # TODO This is not porper way of getting last frame + # - but don't know better + last_frame = frame_start + for layer in layers_data: + frame_end = layer["frame_end"] + if frame_end > last_frame: + last_frame = frame_end + + scene_data = { + "currentFile": workfile_path, + "sceneWidth": width, + "sceneHeight": height, + "pixelAspect": pixel_apsect, + "frameStart": frame_start, + "frameEnd": last_frame, + "fps": frame_rate, + "fieldOrder": field_order + } + self.log.debug( + "Scene data: {}".format(json.dumps(scene_data, indent=4)) + ) + context.data.update(scene_data) diff --git a/pype/plugins/tvpaint/publish/extract_sequence.py b/pype/plugins/tvpaint/publish/extract_sequence.py new file mode 100644 index 0000000000..d173ac287d --- /dev/null +++ b/pype/plugins/tvpaint/publish/extract_sequence.py @@ -0,0 +1,352 @@ +import os +import shutil +import tempfile + +import pyblish.api +from avalon.tvpaint import lib + + +class ExtractSequence(pyblish.api.Extractor): + label = "Extract Sequence" + hosts = ["tvpaint"] + families = ["review", "renderPass", "renderLayer"] + + save_mode_to_ext = { + "avi": ".avi", + "bmp": ".bmp", + "cin": ".cin", + "deep": ".dip", + "dps": ".dps", + "dpx": ".dpx", + "flc": ".fli", + "gif": ".gif", + "ilbm": ".iff", + "jpeg": ".jpg", + "pcx": ".pcx", + "png": ".png", + "psd": ".psd", + "qt": ".qt", + "rtv": ".rtv", + "sun": ".ras", + "tiff": ".tiff", + "tga": ".tga", + "vpb": ".vpb" + } + sequential_save_mode = { + "bmp", + "dpx", + "ilbm", + "jpeg", + "png", + "sun", + "tiff", + "tga" + } + + default_save_mode = "\"PNG\"" + save_mode_for_family = { + "review": "\"PNG\"", + "renderPass": "\"PNG\"", + "renderLayer": "\"PNG\"", + } + + def process(self, instance): + self.log.info( + "* Processing instance \"{}\"".format(instance.data["label"]) + ) + + # Get all layers and filter out not visible + layers = instance.data["layers"] + filtered_layers = [ + layer + for layer in layers + if layer["visible"] + ] + layer_ids = [str(layer["layer_id"]) for layer in filtered_layers] + if not layer_ids: + self.log.info( + f"None of the layers from the instance" + " are visible. Extraction skipped." + ) + return + + self.log.debug( + "Instance has {} layers with ids: {}".format( + len(layer_ids), ", ".join(layer_ids) + ) + ) + # This is plugin attribe cleanup method + self._prepare_save_modes() + + family_lowered = instance.data["family"].lower() + save_mode = self.save_mode_for_family.get( + family_lowered, self.default_save_mode + ) + save_mode_type = self._get_save_mode_type(save_mode) + + if not bool(save_mode_type in self.sequential_save_mode): + raise AssertionError(( + "Plugin can export only sequential frame output" + " but save mode for family \"{}\" is not for sequence > {} <" + ).format(instance.data["family"], save_mode)) + + frame_start = instance.data["frameStart"] + frame_end = instance.data["frameEnd"] + + filename_template = self._get_filename_template( + save_mode_type, save_mode, frame_end + ) + ext = os.path.splitext(filename_template)[1].replace(".", "") + + self.log.debug( + "Using save mode > {} < and file template \"{}\"".format( + save_mode, filename_template + ) + ) + + # Save to staging dir + output_dir = instance.data.get("stagingDir") + if not output_dir: + # Create temp folder if staging dir is not set + output_dir = tempfile.mkdtemp().replace("\\", "/") + instance.data["stagingDir"] = output_dir + + self.log.debug( + "Files will be rendered to folder: {}".format(output_dir) + ) + + thumbnail_filename = "thumbnail" + + # Render output + output_files_by_frame = self.render( + save_mode, filename_template, output_dir, + filtered_layers, frame_start, frame_end, thumbnail_filename + ) + thumbnail_fullpath = output_files_by_frame.pop( + thumbnail_filename, None + ) + + # Fill gaps in sequence + self.fill_missing_frames( + output_files_by_frame, + frame_start, + frame_end, + filename_template + ) + + # Fill tags and new families + tags = [] + if family_lowered in ("review", "renderlayer"): + # Add `ftrackreview` tag + tags.append("ftrackreview") + + repre_files = [ + os.path.basename(filepath) + for filepath in output_files_by_frame.values() + ] + new_repre = { + "name": ext, + "ext": ext, + "files": repre_files, + "stagingDir": output_dir, + "frameStart": frame_start, + "frameEnd": frame_end, + "tags": tags + } + self.log.debug("Creating new representation: {}".format(new_repre)) + + instance.data["representations"].append(new_repre) + + if family_lowered in ("renderpass", "renderlayer"): + # Change family to render + instance.data["family"] = "render" + + if not thumbnail_fullpath: + return + + # Create thumbnail representation + thumbnail_repre = { + "name": "thumbnail", + "ext": ext, + "files": os.path.basename(thumbnail_fullpath), + "stagingDir": output_dir, + "tags": ["thumbnail"] + } + instance.data["representations"].append(thumbnail_repre) + + def _prepare_save_modes(self): + """Lower family names in keys and skip empty values.""" + new_specifications = {} + for key, value in self.save_mode_for_family.items(): + if value: + new_specifications[key.lower()] = value + else: + self.log.warning(( + "Save mode for family \"{}\" has empty value." + " The family will use default save mode: > {} <." + ).format(key, self.default_save_mode)) + self.save_mode_for_family = new_specifications + + def _get_save_mode_type(self, save_mode): + """Extract type of save mode. + + Helps to define output files extension. + """ + save_mode_type = ( + save_mode.lower() + .split(" ")[0] + .replace("\"", "") + ) + self.log.debug("Save mode type is \"{}\"".format(save_mode_type)) + return save_mode_type + + def _get_filename_template(self, save_mode_type, save_mode, frame_end): + """Get filetemplate for rendered files. + + This is simple template contains `{frame}{ext}` for sequential outputs + and `single_file{ext}` for single file output. Output is rendered to + temporary folder so filename should not matter as integrator change + them. + """ + ext = self.save_mode_to_ext.get(save_mode_type) + if ext is None: + raise AssertionError(( + "Couldn't find file extension for TVPaint's save mode: > {} <" + ).format(save_mode)) + + frame_padding = 4 + frame_end_str_len = len(str(frame_end)) + if frame_end_str_len > frame_padding: + frame_padding = frame_end_str_len + + return "{{frame:0>{}}}".format(frame_padding) + ext + + def render( + self, save_mode, filename_template, output_dir, layers, + first_frame, last_frame, thumbnail_filename + ): + """ Export images from TVPaint. + + Args: + save_mode (str): Argument for `tv_savemode` george script function. + More about save mode in documentation. + filename_template (str): Filename template of an output. Template + should already contain extension. Template may contain only + keyword argument `{frame}` or index argument (for same value). + Extension in template must match `save_mode`. + layers (list): List of layers to be exported. + first_frame (int): Starting frame from which export will begin. + last_frame (int): On which frame export will end. + + Retruns: + dict: Mapping frame to output filepath. + """ + + # Add save mode arguments to function + save_mode = "tv_SaveMode {}".format(save_mode) + + # Map layers by position + layers_by_position = { + layer["position"]: layer + for layer in layers + } + + # Sort layer positions in reverse order + sorted_positions = list(reversed(sorted(layers_by_position.keys()))) + if not sorted_positions: + return + + # Create temporary layer + new_layer_id = lib.execute_george("tv_layercreate _tmp_layer") + + # Merge layers to temp layer + george_script_lines = [] + # Set duplicated layer as current + george_script_lines.append("tv_layerset {}".format(new_layer_id)) + for position in sorted_positions: + layer = layers_by_position[position] + george_script_lines.append( + "tv_layermerge {}".format(layer["layer_id"]) + ) + + lib.execute_george_through_file("\n".join(george_script_lines)) + + # Frames with keyframe + exposure_frames = lib.get_exposure_frames( + new_layer_id, first_frame, last_frame + ) + + # TODO what if there is not exposue frames? + # - this force to have first frame all the time + if first_frame not in exposure_frames: + exposure_frames.insert(0, first_frame) + + # Restart george script lines + george_script_lines = [] + george_script_lines.append(save_mode) + + all_output_files = {} + for frame in exposure_frames: + filename = filename_template.format(frame, frame=frame) + dst_path = "/".join([output_dir, filename]) + all_output_files[frame] = os.path.normpath(dst_path) + + # Go to frame + george_script_lines.append("tv_layerImage {}".format(frame)) + # Store image to output + george_script_lines.append("tv_saveimage \"{}\"".format(dst_path)) + + # Export thumbnail + if thumbnail_filename: + basename, ext = os.path.splitext(thumbnail_filename) + if not ext: + ext = ".png" + thumbnail_fullpath = "/".join([output_dir, basename + ext]) + all_output_files[thumbnail_filename] = thumbnail_fullpath + # Force save mode to png for thumbnail + george_script_lines.append("tv_SaveMode \"PNG\"") + # Go to frame + george_script_lines.append("tv_layerImage {}".format(first_frame)) + # Store image to output + george_script_lines.append( + "tv_saveimage \"{}\"".format(thumbnail_fullpath) + ) + + # Delete temporary layer + george_script_lines.append("tv_layerkill {}".format(new_layer_id)) + + lib.execute_george_through_file("\n".join(george_script_lines)) + + return all_output_files + + def fill_missing_frames( + self, filepaths_by_frame, first_frame, last_frame, filename_template + ): + """Fill not rendered frames with previous frame. + + Extractor is rendering only frames with keyframes (exposure frames) to + get output faster which means there may be gaps between frames. + This function fill the missing frames. + """ + output_dir = None + previous_frame_filepath = None + for frame in range(first_frame, last_frame + 1): + if frame in filepaths_by_frame: + previous_frame_filepath = filepaths_by_frame[frame] + continue + + elif previous_frame_filepath is None: + self.log.warning( + "No frames to fill. Seems like nothing was exported." + ) + break + + if output_dir is None: + output_dir = os.path.dirname(previous_frame_filepath) + + filename = filename_template.format(frame=frame) + space_filepath = os.path.normpath( + os.path.join(output_dir, filename) + ) + filepaths_by_frame[frame] = space_filepath + shutil.copy(previous_frame_filepath, space_filepath) diff --git a/pype/plugins/tvpaint/publish/validate_frame_range.py b/pype/plugins/tvpaint/publish/validate_frame_range.py new file mode 100644 index 0000000000..cb9a103fa2 --- /dev/null +++ b/pype/plugins/tvpaint/publish/validate_frame_range.py @@ -0,0 +1,76 @@ +import collections +import pyblish.api + + +class ValidateLayersGroup(pyblish.api.InstancePlugin): + """Validate group ids of renderPass layers. + + Validates that all layers are in same group as they were during creation. + """ + + label = "Validate Layers Group" + order = pyblish.api.ValidatorOrder + families = ["renderPass"] + + def process(self, instance): + # Prepare layers + layers_data = instance.context.data["layersData"] + layers_by_id = { + layer["layer_id"]: layer + for layer in layers_data + } + + # Expected group id for instance layers + group_id = instance.data["group_id"] + # Layers ids of an instance + layer_ids = instance.data["layer_ids"] + # Check if all layers from render pass are in right group + invalid_layers_by_group_id = collections.defaultdict(list) + for layer_id in layer_ids: + layer = layers_by_id.get(layer_id) + _group_id = layer["group_id"] + if _group_id != group_id: + invalid_layers_by_group_id[_group_id].append(layer) + + # Everything is OK and skip exception + if not invalid_layers_by_group_id: + return + + # Exception message preparations + groups_data = instance.context.data["groupsData"] + groups_by_id = { + group["group_id"]: group + for group in groups_data + } + correct_group = groups_by_id[group_id] + + per_group_msgs = [] + for _group_id, layers in invalid_layers_by_group_id.items(): + _group = groups_by_id[_group_id] + layers_msgs = [] + for layer in layers: + layers_msgs.append( + "\"{}\" (id: {})".format(layer["name"], layer["layer_id"]) + ) + per_group_msgs.append( + "Group \"{}\" (id: {}) < {} >".format( + _group["name"], + _group["group_id"], + ", ".join(layers_msgs) + ) + ) + + # Raise an error + raise AssertionError(( + # Short message + "Layers in wrong group." + # Description what's wrong + " Layers from render pass \"{}\" must be in group {} (id: {})." + # Detailed message + " Layers in wrong group: {}" + ).format( + instance.data["label"], + correct_group["name"], + correct_group["group_id"], + " | ".join(per_group_msgs) + ))