Merge pull request #693 from pypeclub/feature/tvpaint_creators

TV Paint: initial implementation with loaders, creators and local rendering
This commit is contained in:
Milan Kolar 2020-11-17 11:57:05 +01:00 committed by GitHub
commit c2dcfc74f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 1212 additions and 5 deletions

View file

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

View file

@ -30,7 +30,8 @@ class ExtractReview(pyblish.api.InstancePlugin):
"premiere",
"harmony",
"standalonepublisher",
"fusion"
"fusion",
"tvpaint"
]
# Supported extensions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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