Merge branch 'develop' into bugfix/AY-854_Nukestudio-crash-if-save-as-new-version-with-workfile

This commit is contained in:
Kayla Man 2024-05-02 21:29:40 +08:00
commit ba45b87d7f
173 changed files with 6110 additions and 3052 deletions

View file

@ -50,7 +50,7 @@ IGNORED_MODULES_IN_AYON = set()
# When addon was moved from ayon-core codebase
# - this is used to log the missing addon
MOVED_ADDON_MILESTONE_VERSIONS = {
"applications": VersionInfo(2, 0, 0),
"applications": VersionInfo(0, 2, 0),
}
# Inherit from `object` for Python 2 hosts

View file

@ -8,14 +8,11 @@ from ayon_core.lib import Logger, register_event_callback
from ayon_core.pipeline import (
register_loader_plugin_path,
register_creator_plugin_path,
register_workfile_build_plugin_path,
AVALON_CONTAINER_ID,
AVALON_INSTANCE_ID,
AYON_INSTANCE_ID,
)
from ayon_core.hosts.aftereffects.api.workfile_template_builder import (
AEPlaceholderLoadPlugin,
AEPlaceholderCreatePlugin
)
from ayon_core.pipeline.load import any_outdated_containers
import ayon_core.hosts.aftereffects
@ -40,6 +37,7 @@ PLUGINS_DIR = os.path.join(HOST_DIR, "plugins")
PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
WORKFILE_BUILD_PATH = os.path.join(PLUGINS_DIR, "workfile_build")
class AfterEffectsHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
@ -76,6 +74,7 @@ class AfterEffectsHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
register_loader_plugin_path(LOAD_PATH)
register_creator_plugin_path(CREATE_PATH)
register_workfile_build_plugin_path(WORKFILE_BUILD_PATH)
register_event_callback("application.launched", application_launch)
@ -118,12 +117,6 @@ class AfterEffectsHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
item["id"] = "publish_context"
self.stub.imprint(item["id"], item)
def get_workfile_build_placeholder_plugins(self):
return [
AEPlaceholderLoadPlugin,
AEPlaceholderCreatePlugin
]
# created instances section
def list_instances(self):
"""List all created instances from current workfile which

View file

@ -1,6 +1,7 @@
import os.path
import uuid
import shutil
from abc import abstractmethod
from ayon_core.pipeline import registered_host
from ayon_core.tools.workfile_template_build import (
@ -9,13 +10,9 @@ from ayon_core.tools.workfile_template_build import (
from ayon_core.pipeline.workfile.workfile_template_builder import (
AbstractTemplateBuilder,
PlaceholderPlugin,
LoadPlaceholderItem,
CreatePlaceholderItem,
PlaceholderLoadMixin,
PlaceholderCreateMixin
PlaceholderItem
)
from ayon_core.hosts.aftereffects.api import get_stub
from ayon_core.hosts.aftereffects.api.lib import set_settings
PLACEHOLDER_SET = "PLACEHOLDERS_SET"
PLACEHOLDER_ID = "openpype.placeholder"
@ -51,6 +48,10 @@ class AETemplateBuilder(AbstractTemplateBuilder):
class AEPlaceholderPlugin(PlaceholderPlugin):
"""Contains generic methods for all PlaceholderPlugins."""
@abstractmethod
def _create_placeholder_item(self, item_data: dict) -> PlaceholderItem:
pass
def collect_placeholders(self):
"""Collect info from file metadata about created placeholders.
@ -63,17 +64,7 @@ class AEPlaceholderPlugin(PlaceholderPlugin):
if item.get("plugin_identifier") != self.identifier:
continue
if isinstance(self, AEPlaceholderLoadPlugin):
item = LoadPlaceholderItem(item["uuid"],
item["data"],
self)
elif isinstance(self, AEPlaceholderCreatePlugin):
item = CreatePlaceholderItem(item["uuid"],
item["data"],
self)
else:
raise NotImplementedError(f"Not implemented for {type(self)}")
item = self._create_placeholder_item(item)
output.append(item)
return output
@ -135,87 +126,6 @@ class AEPlaceholderPlugin(PlaceholderPlugin):
stub.imprint(item_id, container_data)
class AEPlaceholderCreatePlugin(AEPlaceholderPlugin, PlaceholderCreateMixin):
"""Adds Create placeholder.
This adds composition and runs Create
"""
identifier = "aftereffects.create"
label = "AfterEffects create"
def create_placeholder(self, placeholder_data):
stub = get_stub()
name = "CREATEPLACEHOLDER"
item_id = stub.add_item(name, "COMP")
self._imprint_item(item_id, name, placeholder_data, stub)
def populate_placeholder(self, placeholder):
"""Replace 'placeholder' with publishable instance.
Renames prepared composition name, creates publishable instance, sets
frame/duration settings according to DB.
"""
pre_create_data = {"use_selection": True}
item_id, item = self._get_item(placeholder)
get_stub().select_items([item_id])
self.populate_create_placeholder(placeholder, pre_create_data)
# apply settings for populated composition
item_id, metadata_item = self._get_item(placeholder)
set_settings(True, True, [item_id])
def get_placeholder_options(self, options=None):
return self.get_create_plugin_options(options)
class AEPlaceholderLoadPlugin(AEPlaceholderPlugin, PlaceholderLoadMixin):
identifier = "aftereffects.load"
label = "AfterEffects load"
def create_placeholder(self, placeholder_data):
"""Creates AE's Placeholder item in Project items list.
Sets dummy resolution/duration/fps settings, will be replaced when
populated.
"""
stub = get_stub()
name = "LOADERPLACEHOLDER"
item_id = stub.add_placeholder(name, 1920, 1060, 25, 10)
self._imprint_item(item_id, name, placeholder_data, stub)
def populate_placeholder(self, placeholder):
"""Use Openpype Loader from `placeholder` to create new FootageItems
New FootageItems are created, files are imported.
"""
self.populate_load_placeholder(placeholder)
errors = placeholder.get_errors()
stub = get_stub()
if errors:
stub.print_msg("\n".join(errors))
else:
if not placeholder.data["keep_placeholder"]:
metadata = stub.get_metadata()
for item in metadata:
if not item.get("is_placeholder"):
continue
scene_identifier = item.get("uuid")
if (scene_identifier and
scene_identifier == placeholder.scene_identifier):
stub.delete_item(item["members"][0])
stub.remove_instance(placeholder.scene_identifier, metadata)
def get_placeholder_options(self, options=None):
return self.get_load_plugin_options(options)
def load_succeed(self, placeholder, container):
placeholder_item_id, _ = self._get_item(placeholder)
item_id = container.id
get_stub().add_item_instead_placeholder(placeholder_item_id, item_id)
def build_workfile_template(*args, **kwargs):
builder = AETemplateBuilder(registered_host())
builder.build_template(*args, **kwargs)

View file

@ -0,0 +1,49 @@
from ayon_core.pipeline.workfile.workfile_template_builder import (
CreatePlaceholderItem,
PlaceholderCreateMixin
)
from ayon_core.hosts.aftereffects.api import get_stub
from ayon_core.hosts.aftereffects.api.lib import set_settings
import ayon_core.hosts.aftereffects.api.workfile_template_builder as wtb
class AEPlaceholderCreatePlugin(wtb.AEPlaceholderPlugin,
PlaceholderCreateMixin):
"""Adds Create placeholder.
This adds composition and runs Create
"""
identifier = "aftereffects.create"
label = "AfterEffects create"
def _create_placeholder_item(self, item_data) -> CreatePlaceholderItem:
return CreatePlaceholderItem(
scene_identifier=item_data["uuid"],
data=item_data["data"],
plugin=self
)
def create_placeholder(self, placeholder_data):
stub = get_stub()
name = "CREATEPLACEHOLDER"
item_id = stub.add_item(name, "COMP")
self._imprint_item(item_id, name, placeholder_data, stub)
def populate_placeholder(self, placeholder):
"""Replace 'placeholder' with publishable instance.
Renames prepared composition name, creates publishable instance, sets
frame/duration settings according to DB.
"""
pre_create_data = {"use_selection": True}
item_id, item = self._get_item(placeholder)
get_stub().select_items([item_id])
self.populate_create_placeholder(placeholder, pre_create_data)
# apply settings for populated composition
item_id, metadata_item = self._get_item(placeholder)
set_settings(True, True, [item_id])
def get_placeholder_options(self, options=None):
return self.get_create_plugin_options(options)

View file

@ -0,0 +1,60 @@
from ayon_core.pipeline.workfile.workfile_template_builder import (
LoadPlaceholderItem,
PlaceholderLoadMixin
)
from ayon_core.hosts.aftereffects.api import get_stub
import ayon_core.hosts.aftereffects.api.workfile_template_builder as wtb
class AEPlaceholderLoadPlugin(wtb.AEPlaceholderPlugin, PlaceholderLoadMixin):
identifier = "aftereffects.load"
label = "AfterEffects load"
def _create_placeholder_item(self, item_data) -> LoadPlaceholderItem:
return LoadPlaceholderItem(
scene_identifier=item_data["uuid"],
data=item_data["data"],
plugin=self
)
def create_placeholder(self, placeholder_data):
"""Creates AE's Placeholder item in Project items list.
Sets dummy resolution/duration/fps settings, will be replaced when
populated.
"""
stub = get_stub()
name = "LOADERPLACEHOLDER"
item_id = stub.add_placeholder(name, 1920, 1060, 25, 10)
self._imprint_item(item_id, name, placeholder_data, stub)
def populate_placeholder(self, placeholder):
"""Use Openpype Loader from `placeholder` to create new FootageItems
New FootageItems are created, files are imported.
"""
self.populate_load_placeholder(placeholder)
errors = placeholder.get_errors()
stub = get_stub()
if errors:
stub.print_msg("\n".join(errors))
else:
if not placeholder.data["keep_placeholder"]:
metadata = stub.get_metadata()
for item in metadata:
if not item.get("is_placeholder"):
continue
scene_identifier = item.get("uuid")
if (scene_identifier and
scene_identifier == placeholder.scene_identifier):
stub.delete_item(item["members"][0])
stub.remove_instance(placeholder.scene_identifier, metadata)
def get_placeholder_options(self, options=None):
return self.get_load_plugin_options(options)
def load_succeed(self, placeholder, container):
placeholder_item_id, _ = self._get_item(placeholder)
item_id = container.id
get_stub().add_item_instead_placeholder(placeholder_item_id, item_id)

View file

@ -2,6 +2,7 @@ import os
import bpy
from ayon_core.lib import BoolDef
from ayon_core.pipeline import publish
from ayon_core.hosts.blender.api import plugin
@ -17,6 +18,8 @@ class ExtractABC(publish.Extractor, publish.OptionalPyblishPluginMixin):
if not self.is_active(instance.data):
return
attr_values = self.get_attr_values_from_data(instance.data)
# Define extract output file path
stagingdir = self.staging_dir(instance)
folder_name = instance.data["folderEntity"]["name"]
@ -46,7 +49,8 @@ class ExtractABC(publish.Extractor, publish.OptionalPyblishPluginMixin):
bpy.ops.wm.alembic_export(
filepath=filepath,
selected=True,
flatten=False
flatten=False,
subdiv_schema=attr_values.get("subdiv_schema", False)
)
plugin.deselect_all()
@ -65,6 +69,21 @@ class ExtractABC(publish.Extractor, publish.OptionalPyblishPluginMixin):
self.log.debug("Extracted instance '%s' to: %s",
instance.name, representation)
@classmethod
def get_attribute_defs(cls):
return [
BoolDef(
"subdiv_schema",
label="Alembic Mesh Subdiv Schema",
tooltip="Export Meshes using Alembic's subdivision schema.\n"
"Enabling this includes creases with the export but "
"excludes the mesh's normals.\n"
"Enabling this usually result in smaller file size "
"due to lack of normals.",
default=False
)
]
class ExtractModelABC(ExtractABC):
"""Extract model as ABC."""

View file

@ -1,5 +1,5 @@
import os
from ayon_core.lib import PreLaunchHook
from ayon_applications import PreLaunchHook
from ayon_core.hosts.fusion import FUSION_HOST_DIR

View file

@ -8,6 +8,7 @@ from .lib import (
sync_avalon_data_to_workfile,
launch_workfiles_app,
before_project_save,
apply_colorspace_project
)
from .tags import add_tags_to_workfile
from .menu import update_menu_task_label
@ -44,6 +45,8 @@ def afterNewProjectCreated(event):
# reset workfiles startup not to open any more in session
os.environ["WORKFILES_STARTUP"] = "0"
apply_colorspace_project()
def beforeProjectLoad(event):
log.info("before project load event...")
@ -122,6 +125,7 @@ def register_hiero_events():
except RuntimeError:
pass
def register_events():
"""
Adding all callbacks.

View file

@ -11,7 +11,6 @@ import warnings
import json
import ast
import secrets
import shutil
import hiero
from qtpy import QtWidgets, QtCore
@ -36,9 +35,6 @@ from .constants import (
DEFAULT_SEQUENCE_NAME,
DEFAULT_BIN_NAME
)
from ayon_core.pipeline.colorspace import (
get_imageio_config
)
class _CTX:
@ -105,9 +101,9 @@ def flatten(list_):
def get_current_project(remove_untitled=False):
projects = flatten(hiero.core.projects())
projects = hiero.core.projects()
if not remove_untitled:
return next(iter(projects))
return projects[0]
# if remove_untitled
for proj in projects:
@ -1050,18 +1046,68 @@ def _set_hrox_project_knobs(doc, **knobs):
def apply_colorspace_project():
project_name = get_current_project_name()
# get path the the active projects
project = get_current_project(remove_untitled=True)
current_file = project.path()
# close the active project
project.close()
"""Apply colorspaces from settings.
Due to not being able to set the project settings through the Python API,
we need to do use some dubious code to find the widgets and set them. It is
possible to set the project settings without traversing through the widgets
but it involves reading the hrox files from disk with XML, so no in-memory
support. See https://community.foundry.com/discuss/topic/137771/change-a-project-s-default-color-transform-with-python # noqa
for more details.
"""
# get presets for hiero
project_name = get_current_project_name()
imageio = get_project_settings(project_name)["hiero"]["imageio"]
presets = imageio.get("workfile")
# Open Project Settings UI.
for act in hiero.ui.registeredActions():
if act.objectName() == "foundry.project.settings":
act.trigger()
# Find widgets from their sibling label.
labels = {
"Working Space:": "workingSpace",
"Viewer:": "viewerLut",
"Thumbnails:": "thumbnailLut",
"Monitor Out:": "monitorOutLut",
"8 Bit Files:": "eightBitLut",
"16 Bit Files:": "sixteenBitLut",
"Log Files:": "logLut",
"Floating Point Files:": "floatLut"
}
widgets = {x: None for x in labels.values()}
def _recursive_children(widget, labels, widgets):
children = widget.children()
for count, child in enumerate(children):
if isinstance(child, QtWidgets.QLabel):
if child.text() in labels.keys():
widgets[labels[child.text()]] = children[count + 1]
_recursive_children(child, labels, widgets)
app = QtWidgets.QApplication.instance()
title = "Project Settings"
for widget in app.topLevelWidgets():
if isinstance(widget, QtWidgets.QMainWindow):
if widget.windowTitle() != title:
continue
_recursive_children(widget, labels, widgets)
widget.close()
msg = "Setting value \"{}\" is not a valid option for \"{}\""
for key, widget in widgets.items():
options = [widget.itemText(i) for i in range(widget.count())]
setting_value = presets[key]
assert setting_value in options, msg.format(setting_value, key)
widget.setCurrentText(presets[key])
# This code block is for setting up project colorspaces for files on disk.
# Due to not having Python API access to set the project settings, the
# Foundry recommended way is to modify the hrox files on disk with XML. See
# this forum thread for more details;
# https://community.foundry.com/discuss/topic/137771/change-a-project-s-default-color-transform-with-python # noqa
'''
# backward compatibility layer
# TODO: remove this after some time
config_data = get_imageio_config(
@ -1074,6 +1120,13 @@ def apply_colorspace_project():
"ocioConfigName": "custom"
})
# get path the the active projects
project = get_current_project()
current_file = project.path()
msg = "The project needs to be saved to disk to apply colorspace settings."
assert current_file, msg
# save the workfile as subversion "comment:_colorspaceChange"
split_current_file = os.path.splitext(current_file)
copy_current_file = current_file
@ -1116,6 +1169,7 @@ def apply_colorspace_project():
# open the file as current project
hiero.core.openProject(copy_current_file)
'''
def apply_colorspace_clips():
@ -1125,10 +1179,8 @@ def apply_colorspace_clips():
# get presets for hiero
imageio = get_project_settings(project_name)["hiero"]["imageio"]
from pprint import pprint
presets = imageio.get("regexInputs", {}).get("inputs", {})
pprint(presets)
for clip in clips:
clip_media_source_path = clip.mediaSource().firstpath()
clip_name = clip.name()

View file

@ -144,7 +144,7 @@ def add_tags_to_workfile():
# Get project task types.
project_name = get_current_project_name()
project_entity = ayon_api.get_project(project_name)
task_types = project_entity["taskType"]
task_types = project_entity["taskTypes"]
nks_pres_tags["[Tasks]"] = {}
log.debug("__ tasks: {}".format(task_types))
for task_type in task_types:

View file

@ -811,6 +811,43 @@ def get_current_context_template_data_with_folder_attrs():
return template_data
def set_review_color_space(opengl_node, review_color_space="", log=None):
"""Set ociocolorspace parameter for the given OpenGL node.
Set `ociocolorspace` parameter of the given OpenGl node
to to the given review_color_space value.
If review_color_space is empty, a default colorspace corresponding to
the display & view of the current Houdini session will be used.
Args:
opengl_node (hou.Node): ROP node to set its ociocolorspace parm.
review_color_space (str): Colorspace value for ociocolorspace parm.
log (logging.Logger): Logger to log to.
"""
if log is None:
log = self.log
# Set Color Correction parameter to OpenColorIO
colorcorrect_parm = opengl_node.parm("colorcorrect")
if colorcorrect_parm.eval() != 2:
colorcorrect_parm.set(2)
log.debug(
"'Color Correction' parm on '{}' has been set to"
" 'OpenColorIO'".format(opengl_node.path())
)
opengl_node.setParms(
{"ociocolorspace": review_color_space}
)
log.debug(
"'OCIO Colorspace' parm on '{}' has been set to "
"the view color space '{}'"
.format(opengl_node, review_color_space)
)
def get_context_var_changes():
"""get context var changes."""

View file

@ -0,0 +1,64 @@
from ayon_applications import PreLaunchHook, LaunchTypes
class SetDefaultDisplayView(PreLaunchHook):
"""Set default view and default display for houdini via OpenColorIO.
Houdini's defaultDisplay and defaultView are set by
setting 'OCIO_ACTIVE_DISPLAYS' and 'OCIO_ACTIVE_VIEWS'
environment variables respectively.
More info: https://www.sidefx.com/docs/houdini/io/ocio.html#set-up
"""
app_groups = {"houdini"}
launch_types = {LaunchTypes.local}
def execute(self):
OCIO = self.launch_context.env.get("OCIO")
# This is a cheap way to skip this hook if either global color
# management or houdini color management was disabled because the
# OCIO var would be set by the global OCIOEnvHook
if not OCIO:
return
# workfile settings added in '0.2.13'
houdini_color_settings = \
self.data["project_settings"]["houdini"]["imageio"].get("workfile")
if not houdini_color_settings:
self.log.info("Hook 'SetDefaultDisplayView' requires Houdini "
"addon version >= '0.2.13'")
return
if not houdini_color_settings["enabled"]:
self.log.info(
"Houdini workfile color management is disabled."
)
return
# 'OCIO_ACTIVE_DISPLAYS', 'OCIO_ACTIVE_VIEWS' are checked
# as Admins can add them in Ayon env vars or Ayon tools.
default_display = houdini_color_settings["default_display"]
if default_display:
# get 'OCIO_ACTIVE_DISPLAYS' value if exists.
self._set_context_env("OCIO_ACTIVE_DISPLAYS", default_display)
default_view = houdini_color_settings["default_view"]
if default_view:
# get 'OCIO_ACTIVE_VIEWS' value if exists.
self._set_context_env("OCIO_ACTIVE_VIEWS", default_view)
def _set_context_env(self, env_var, default_value):
env_value = self.launch_context.env.get(env_var, "")
new_value = ":".join(
key for key in [default_value, env_value] if key
)
self.log.info(
"Setting {} environment to: {}"
.format(env_var, new_value)
)
self.launch_context.env[env_var] = new_value

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""Creator plugin for creating openGL reviews."""
from ayon_core.hosts.houdini.api import plugin
from ayon_core.hosts.houdini.api import lib, plugin
from ayon_core.lib import EnumDef, BoolDef, NumberDef
import os
@ -14,6 +14,16 @@ class CreateReview(plugin.HoudiniCreator):
label = "Review"
product_type = "review"
icon = "video-camera"
review_color_space = ""
def apply_settings(self, project_settings):
super(CreateReview, self).apply_settings(project_settings)
# workfile settings added in '0.2.13'
color_settings = project_settings["houdini"]["imageio"].get(
"workfile", {}
)
if color_settings.get("enabled"):
self.review_color_space = color_settings.get("review_color_space")
def create(self, product_name, instance_data, pre_create_data):
@ -85,10 +95,20 @@ class CreateReview(plugin.HoudiniCreator):
instance_node.setParms(parms)
# Set OCIO Colorspace to the default output colorspace
# Set OCIO Colorspace to the default colorspace
# if there's OCIO
if os.getenv("OCIO"):
self.set_colorcorrect_to_default_view_space(instance_node)
# Fall to the default value if cls.review_color_space is empty.
if not self.review_color_space:
# cls.review_color_space is an empty string
# when the imageio/workfile setting is disabled or
# when the Review colorspace setting is empty.
from ayon_core.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa
self.review_color_space = get_default_display_view_colorspace()
lib.set_review_color_space(instance_node,
self.review_color_space,
self.log)
to_lock = ["id", "productType"]
@ -131,23 +151,3 @@ class CreateReview(plugin.HoudiniCreator):
minimum=0.0001,
decimals=3)
]
def set_colorcorrect_to_default_view_space(self,
instance_node):
"""Set ociocolorspace to the default output space."""
from ayon_core.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa
# set Color Correction parameter to OpenColorIO
instance_node.setParms({"colorcorrect": 2})
# Get default view space for ociocolorspace parm.
default_view_space = get_default_display_view_colorspace()
instance_node.setParms(
{"ociocolorspace": default_view_space}
)
self.log.debug(
"'OCIO Colorspace' parm on '{}' has been set to "
"the default view color space '{}'"
.format(instance_node, default_view_space)
)

View file

@ -45,33 +45,11 @@ class AbcLoader(load.LoaderPlugin):
alembic = container.createNode("alembic", node_name=node_name)
alembic.setParms({"fileName": file_path})
# Add unpack node
unpack_name = "unpack_{}".format(name)
unpack = container.createNode("unpack", node_name=unpack_name)
unpack.setInput(0, alembic)
unpack.setParms({"transfer_attributes": "path"})
# Position nodes nicely
container.moveToGoodPosition()
container.layoutChildren()
# Add normal to points
# Order of menu ['point', 'vertex', 'prim', 'detail']
normal_name = "normal_{}".format(name)
normal_node = container.createNode("normal", node_name=normal_name)
normal_node.setParms({"type": 0})
normal_node.setInput(0, unpack)
null = container.createNode("null", node_name="OUT")
null.setInput(0, normal_node)
# Ensure display flag is on the Alembic input node and not on the OUT
# node to optimize "debug" displaying in the viewport.
alembic.setDisplayFlag(True)
# Set new position for unpack node else it gets cluttered
nodes = [container, alembic, unpack, normal_node, null]
for nr, node in enumerate(nodes):
node.setPosition([0, (0 - nr)])
self[:] = nodes
nodes = [container, alembic]
return pipeline.containerise(
node_name,

View file

@ -7,7 +7,8 @@ from ayon_core.hosts.houdini.api.lib import render_rop, splitext
import hou
class ExtractComposite(publish.Extractor):
class ExtractComposite(publish.Extractor,
publish.ColormanagedPyblishPluginMixin):
order = pyblish.api.ExtractorOrder
label = "Extract Composite (Image Sequence)"
@ -45,8 +46,14 @@ class ExtractComposite(publish.Extractor):
"frameEnd": instance.data["frameEndHandle"],
}
from pprint import pformat
self.log.info(pformat(representation))
if ext.lower() == "exr":
# Inject colorspace with 'scene_linear' as that's the
# default Houdini working colorspace and all extracted
# OpenEXR images should be in that colorspace.
# https://www.sidefx.com/docs/houdini/render/linear.html#image-formats
self.set_representation_colorspace(
representation, instance.context,
colorspace="scene_linear"
)
instance.data["representations"].append(representation)

View file

@ -8,7 +8,8 @@ from ayon_core.hosts.houdini.api.lib import render_rop
import hou
class ExtractOpenGL(publish.Extractor):
class ExtractOpenGL(publish.Extractor,
publish.ColormanagedPyblishPluginMixin):
order = pyblish.api.ExtractorOrder - 0.01
label = "Extract OpenGL"
@ -46,6 +47,14 @@ class ExtractOpenGL(publish.Extractor):
"camera_name": instance.data.get("review_camera")
}
if ropnode.evalParm("colorcorrect") == 2: # OpenColorIO enabled
colorspace = ropnode.evalParm("ociocolorspace")
# inject colorspace data
self.set_representation_colorspace(
representation, instance.context,
colorspace=colorspace
)
if "representations" not in instance.data:
instance.data["representations"] = []
instance.data["representations"].append(representation)

View file

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
import sys
import hou
import pyblish.api
import six
from ayon_core.pipeline import PublishValidationError
@ -26,28 +25,21 @@ class ValidateCopOutputNode(pyblish.api.InstancePlugin):
invalid = self.get_invalid(instance)
if invalid:
raise PublishValidationError(
("Output node(s) `{}` are incorrect. "
"See plug-in log for details.").format(invalid),
title=self.label
"Output node '{}' is incorrect. "
"See plug-in log for details.".format(invalid),
title=self.label,
description=(
"### Invalid COP output node\n\n"
"The output node path for the instance must be set to a "
"valid COP node path.\n\nSee the log for more details."
)
)
@classmethod
def get_invalid(cls, instance):
output_node = instance.data.get("output_node")
import hou
try:
output_node = instance.data["output_node"]
except KeyError:
six.reraise(
PublishValidationError,
PublishValidationError(
"Can't determine COP output node.",
title=cls.__name__),
sys.exc_info()[2]
)
if output_node is None:
if not output_node:
node = hou.node(instance.data.get("instance_node"))
cls.log.error(
"COP Output node in '%s' does not exist. "
@ -61,8 +53,8 @@ class ValidateCopOutputNode(pyblish.api.InstancePlugin):
cls.log.error(
"Output node %s is not a COP node. "
"COP Path must point to a COP node, "
"instead found category type: %s"
% (output_node.path(), output_node.type().category().name())
"instead found category type: %s",
output_node.path(), output_node.type().category().name()
)
return [output_node.path()]
@ -70,9 +62,7 @@ class ValidateCopOutputNode(pyblish.api.InstancePlugin):
# is Cop2 to avoid potential edge case scenarios even though
# the isinstance check above should be stricter than this category
if output_node.type().category().name() != "Cop2":
raise PublishValidationError(
(
"Output node {} is not of category Cop2."
" This is a bug..."
).format(output_node.path()),
title=cls.label)
cls.log.error(
"Output node %s is not of category Cop2.", output_node.path()
)
return [output_node.path()]

View file

@ -4,15 +4,19 @@ from ayon_core.pipeline import (
PublishValidationError,
OptionalPyblishPluginMixin
)
from ayon_core.pipeline.publish import RepairAction
from ayon_core.pipeline.publish import (
RepairAction,
get_plugin_settings,
apply_plugin_settings_automatically
)
from ayon_core.hosts.houdini.api.action import SelectROPAction
import os
import hou
class SetDefaultViewSpaceAction(RepairAction):
label = "Set default view colorspace"
class ResetViewSpaceAction(RepairAction):
label = "Reset OCIO colorspace parm"
icon = "mdi.monitor"
@ -27,9 +31,28 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin,
families = ["review"]
hosts = ["houdini"]
label = "Validate Review Colorspace"
actions = [SetDefaultViewSpaceAction, SelectROPAction]
actions = [ResetViewSpaceAction, SelectROPAction]
optional = True
review_color_space = ""
@classmethod
def apply_settings(cls, project_settings):
# Preserve automatic settings applying logic
settings = get_plugin_settings(plugin=cls,
project_settings=project_settings,
log=cls.log,
category="houdini")
apply_plugin_settings_automatically(cls, settings, logger=cls.log)
# workfile settings added in '0.2.13'
color_settings = project_settings["houdini"]["imageio"].get(
"workfile", {}
)
# Add review color settings
if color_settings.get("enabled"):
cls.review_color_space = color_settings.get("review_color_space")
def process(self, instance):
@ -52,39 +75,54 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin,
" 'OpenColorIO'".format(rop_node.path())
)
if rop_node.evalParm("ociocolorspace") not in \
hou.Color.ocio_spaces():
current_color_space = rop_node.evalParm("ociocolorspace")
if current_color_space not in hou.Color.ocio_spaces():
raise PublishValidationError(
"Invalid value: Colorspace name doesn't exist.\n"
"Check 'OCIO Colorspace' parameter on '{}' ROP"
.format(rop_node.path())
)
@classmethod
def repair(cls, instance):
"""Set Default View Space Action.
# if houdini/imageio/workfile is enabled and
# Review colorspace setting is empty then this check should
# actually check if the current_color_space setting equals
# the default colorspace value.
# However, it will make the black cmd screen show up more often
# which is very annoying.
if self.review_color_space and \
self.review_color_space != current_color_space:
It is a helper action more than a repair action,
used to set colorspace on opengl node to the default view.
"""
from ayon_core.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa
rop_node = hou.node(instance.data["instance_node"])
if rop_node.evalParm("colorcorrect") != 2:
rop_node.setParms({"colorcorrect": 2})
cls.log.debug(
"'Color Correction' parm on '{}' has been set to"
" 'OpenColorIO'".format(rop_node.path())
raise PublishValidationError(
"Invalid value: Colorspace name doesn't match"
"the Colorspace specified in settings."
)
# Get default view colorspace name
default_view_space = get_default_display_view_colorspace()
@classmethod
def repair(cls, instance):
"""Reset view colorspace.
rop_node.setParms({"ociocolorspace": default_view_space})
cls.log.info(
"'OCIO Colorspace' parm on '{}' has been set to "
"the default view color space '{}'"
.format(rop_node, default_view_space)
)
It is used to set colorspace on opengl node.
It uses the colorspace value specified in the Houdini addon settings.
If the value in the Houdini addon settings is empty,
it will fall to the default colorspace.
Note:
This repair action assumes that OCIO is enabled.
As if OCIO is disabled the whole validation is skipped
and this repair action won't show up.
"""
from ayon_core.hosts.houdini.api.lib import set_review_color_space
# Fall to the default value if cls.review_color_space is empty.
if not cls.review_color_space:
# cls.review_color_space is an empty string
# when the imageio/workfile setting is disabled or
# when the Review colorspace setting is empty.
from ayon_core.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa
cls.review_color_space = get_default_display_view_colorspace()
rop_node = hou.node(instance.data["instance_node"])
set_review_color_space(rop_node,
cls.review_color_space,
cls.log)

View file

@ -8,10 +8,15 @@ from typing import Any, Dict, Union
import six
import ayon_api
from ayon_core.pipeline import get_current_project_name, colorspace
from ayon_core.pipeline import (
get_current_project_name,
get_current_folder_path,
get_current_task_name,
colorspace
)
from ayon_core.settings import get_project_settings
from ayon_core.pipeline.context_tools import (
get_current_folder_entity,
get_current_task_entity
)
from ayon_core.style import load_stylesheet
from pymxs import runtime as rt
@ -221,41 +226,30 @@ def reset_scene_resolution():
scene resolution can be overwritten by a folder if the folder.attrib
contains any information regarding scene resolution.
"""
folder_entity = get_current_folder_entity(
fields={"attrib.resolutionWidth", "attrib.resolutionHeight"}
)
folder_attributes = folder_entity["attrib"]
width = int(folder_attributes["resolutionWidth"])
height = int(folder_attributes["resolutionHeight"])
task_attributes = get_current_task_entity(fields={"attrib"})["attrib"]
width = int(task_attributes["resolutionWidth"])
height = int(task_attributes["resolutionHeight"])
set_scene_resolution(width, height)
def get_frame_range(folder_entiy=None) -> Union[Dict[str, Any], None]:
"""Get the current folder frame range and handles.
def get_frame_range(task_entity=None) -> Union[Dict[str, Any], None]:
"""Get the current task frame range and handles
Args:
folder_entiy (dict): Folder eneity.
task_entity (dict): Task Entity.
Returns:
dict: with frame start, frame end, handle start, handle end.
"""
# Set frame start/end
if folder_entiy is None:
folder_entiy = get_current_folder_entity()
folder_attributes = folder_entiy["attrib"]
frame_start = folder_attributes.get("frameStart")
frame_end = folder_attributes.get("frameEnd")
if frame_start is None or frame_end is None:
return {}
frame_start = int(frame_start)
frame_end = int(frame_end)
handle_start = int(folder_attributes.get("handleStart", 0))
handle_end = int(folder_attributes.get("handleEnd", 0))
if task_entity is None:
task_entity = get_current_task_entity(fields={"attrib"})
task_attributes = task_entity["attrib"]
frame_start = int(task_attributes["frameStart"])
frame_end = int(task_attributes["frameEnd"])
handle_start = int(task_attributes["handleStart"])
handle_end = int(task_attributes["handleEnd"])
frame_start_handle = frame_start - handle_start
frame_end_handle = frame_end + handle_end
@ -281,9 +275,9 @@ def reset_frame_range(fps: bool = True):
scene frame rate in frames-per-second.
"""
if fps:
project_name = get_current_project_name()
project_entity = ayon_api.get_project(project_name)
fps_number = float(project_entity["attrib"].get("fps"))
task_entity = get_current_task_entity()
task_attributes = task_entity["attrib"]
fps_number = float(task_attributes["fps"])
rt.frameRate = fps_number
frame_range = get_frame_range()
@ -502,9 +496,9 @@ def object_transform_set(container_children):
"""
transform_set = {}
for node in container_children:
name = f"{node.name}.transform"
name = f"{node}.transform"
transform_set[name] = node.pos
name = f"{node.name}.scale"
name = f"{node}.scale"
transform_set[name] = node.scale
return transform_set
@ -525,6 +519,36 @@ def get_plugins() -> list:
return plugin_info_list
def update_modifier_node_names(event, node):
"""Update the name of the nodes after renaming
Args:
event (pymxs.MXSWrapperBase): Event Name (
Mandatory argument for rt.NodeEventCallback)
node (list): Event Number (
Mandatory argument for rt.NodeEventCallback)
"""
containers = [
obj
for obj in rt.Objects
if (
rt.ClassOf(obj) == rt.Container
and rt.getUserProp(obj, "id") == "pyblish.avalon.instance"
and rt.getUserProp(obj, "productType") not in {
"workfile", "tyflow"
}
)
]
if not containers:
return
for container in containers:
ayon_data = container.modifiers[0].openPypeData
updated_node_names = [str(node.node) for node
in ayon_data.all_handles]
rt.setProperty(ayon_data, "sel_list", updated_node_names)
@contextlib.contextmanager
def render_resolution(width, height):
"""Set render resolution option during context

View file

@ -63,6 +63,8 @@ class MaxHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
rt.callbacks.addScript(rt.Name('postWorkspaceChange'),
self._deferred_menu_creation)
rt.NodeEventCallback(
nameChanged=lib.update_modifier_node_names)
def workfile_has_unsaved_changes(self):
return rt.getSaveRequired()

View file

@ -117,7 +117,7 @@ class MaxSceneLoader(load.LoaderPlugin):
)
for max_obj, obj_name in zip(max_objects, max_object_names):
max_obj.name = f"{namespace}:{obj_name}"
max_container.append(rt.getNodeByName(max_obj.name))
max_container.append(max_obj)
return containerise(
name, max_container, context,
namespace, loader=self.__class__.__name__)
@ -158,11 +158,11 @@ class MaxSceneLoader(load.LoaderPlugin):
current_max_object_names):
max_obj.name = f"{namespace}:{obj_name}"
max_objects.append(max_obj)
max_transform = f"{max_obj.name}.transform"
max_transform = f"{max_obj}.transform"
if max_transform in transform_data.keys():
max_obj.pos = transform_data[max_transform] or 0
max_obj.scale = transform_data[
f"{max_obj.name}.scale"] or 0
f"{max_obj}.scale"] or 0
update_custom_attribute_data(node, max_objects)
lib.imprint(container["instance_node"], {

View file

@ -76,11 +76,11 @@ class FbxModelLoader(load.LoaderPlugin):
for fbx_object in current_fbx_objects:
fbx_object.name = f"{namespace}:{fbx_object.name}"
fbx_objects.append(fbx_object)
fbx_transform = f"{fbx_object.name}.transform"
fbx_transform = f"{fbx_object}.transform"
if fbx_transform in transform_data.keys():
fbx_object.pos = transform_data[fbx_transform] or 0
fbx_object.scale = transform_data[
f"{fbx_object.name}.scale"] or 0
f"{fbx_object}.scale"] or 0
with maintained_selection():
rt.Select(node)

View file

@ -67,11 +67,11 @@ class ObjLoader(load.LoaderPlugin):
selections = rt.GetCurrentSelection()
for selection in selections:
selection.name = f"{namespace}:{selection.name}"
selection_transform = f"{selection.name}.transform"
selection_transform = f"{selection}.transform"
if selection_transform in transform_data.keys():
selection.pos = transform_data[selection_transform] or 0
selection.scale = transform_data[
f"{selection.name}.scale"] or 0
f"{selection}.scale"] or 0
update_custom_attribute_data(node, selections)
with maintained_selection():
rt.Select(node)

View file

@ -95,11 +95,11 @@ class ModelUSDLoader(load.LoaderPlugin):
for children in asset.Children:
children.name = f"{namespace}:{children.name}"
usd_objects.append(children)
children_transform = f"{children.name}.transform"
children_transform = f"{children}.transform"
if children_transform in transform_data.keys():
children.pos = transform_data[children_transform] or 0
children.scale = transform_data[
f"{children.name}.scale"] or 0
f"{children}.scale"] or 0
asset.name = f"{namespace}:{asset.name}"
usd_objects.append(asset)

View file

@ -92,10 +92,10 @@ class OxAbcLoader(load.LoaderPlugin):
abc.Parent = container
abc.name = f"{namespace}:{abc.name}"
ox_abc_objects.append(abc)
ox_transform = f"{abc.name}.transform"
ox_transform = f"{abc}.transform"
if ox_transform in transform_data.keys():
abc.pos = transform_data[ox_transform] or 0
abc.scale = transform_data[f"{abc.name}.scale"] or 0
abc.scale = transform_data[f"{abc}.scale"] or 0
update_custom_attribute_data(node, ox_abc_objects)
lib.imprint(
container["instance_node"],

View file

@ -53,6 +53,7 @@ class ExtractAlembic(publish.Extractor,
hosts = ["max"]
families = ["pointcache"]
optional = True
active = True
def process(self, instance):
if not self.is_active(instance.data):
@ -102,24 +103,27 @@ class ExtractAlembic(publish.Extractor,
@classmethod
def get_attribute_defs(cls):
return [
defs = super(ExtractAlembic, cls).get_attribute_defs()
defs.extend([
BoolDef("custom_attrs",
label="Custom Attributes",
default=False),
]
])
return defs
class ExtractCameraAlembic(ExtractAlembic):
"""Extract Camera with AlembicExport."""
label = "Extract Alembic Camera"
families = ["camera"]
optional = True
class ExtractModel(ExtractAlembic):
class ExtractModelAlembic(ExtractAlembic):
"""Extract Geometry in Alembic Format"""
label = "Extract Geometry (Alembic)"
families = ["model"]
optional = True
def _set_abc_attributes(self, instance):
attr_values = self.get_attr_values_from_data(instance.data)

View file

@ -42,7 +42,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin,
return
frame_range = get_frame_range(
instance.data["folderEntity"])
instance.data["taskEntity"])
inst_frame_start = instance.data.get("frameStartHandle")
inst_frame_end = instance.data.get("frameEndHandle")

View file

@ -38,7 +38,7 @@ class ValidateInstanceInContext(pyblish.api.InstancePlugin,
context_label = "{} > {}".format(*context)
instance_label = "{} > {}".format(folderPath, task)
message = (
"Instance '{}' publishes to different folder or task "
"Instance '{}' publishes to different context(folder or task) "
"than current context: {}. Current context: {}".format(
instance.name, instance_label, context_label
)
@ -46,7 +46,7 @@ class ValidateInstanceInContext(pyblish.api.InstancePlugin,
raise PublishValidationError(
message=message,
description=(
"## Publishing to a different context folder or task\n"
"## Publishing to a different context data(folder or task)\n"
"There are publish instances present which are publishing "
"into a different folder path or task than your current context.\n\n"
"Usually this is not what you want but there can be cases "

View file

@ -7,7 +7,10 @@ from ayon_core.pipeline.publish import (
RepairAction,
PublishValidationError
)
from ayon_core.hosts.max.api.lib import reset_scene_resolution
from ayon_core.hosts.max.api.lib import (
reset_scene_resolution,
imprint
)
class ValidateResolutionSetting(pyblish.api.InstancePlugin,
@ -25,8 +28,10 @@ class ValidateResolutionSetting(pyblish.api.InstancePlugin,
if not self.is_active(instance.data):
return
width, height = self.get_folder_resolution(instance)
current_width = rt.renderWidth
current_height = rt.renderHeight
current_width, current_height = (
self.get_current_resolution(instance)
)
if current_width != width and current_height != height:
raise PublishValidationError("Resolution Setting "
"not matching resolution "
@ -41,12 +46,16 @@ class ValidateResolutionSetting(pyblish.api.InstancePlugin,
"not matching resolution set "
"on asset or shot.")
def get_folder_resolution(self, instance):
folder_entity = instance.data["folderEntity"]
if folder_entity:
folder_attributes = folder_entity["attrib"]
width = folder_attributes["resolutionWidth"]
height = folder_attributes["resolutionHeight"]
def get_current_resolution(self, instance):
return rt.renderWidth, rt.renderHeight
@classmethod
def get_folder_resolution(cls, instance):
task_entity = instance.data.get("taskEntity")
if task_entity:
task_attributes = task_entity["attrib"]
width = task_attributes["resolutionWidth"]
height = task_attributes["resolutionHeight"]
return int(width), int(height)
# Defaults if not found in folder entity
@ -55,3 +64,29 @@ class ValidateResolutionSetting(pyblish.api.InstancePlugin,
@classmethod
def repair(cls, instance):
reset_scene_resolution()
class ValidateReviewResolutionSetting(ValidateResolutionSetting):
families = ["review"]
optional = True
actions = [RepairAction]
def get_current_resolution(self, instance):
current_width = instance.data["review_width"]
current_height = instance.data["review_height"]
return current_width, current_height
@classmethod
def repair(cls, instance):
context_width, context_height = (
cls.get_folder_resolution(instance)
)
creator_attrs = instance.data["creator_attributes"]
creator_attrs["review_width"] = context_width
creator_attrs["review_height"] = context_height
creator_attrs_data = {
"creator_attributes": creator_attrs
}
# update the width and height of review
# data in creator_attributes
imprint(instance.data["instance_node"], creator_attrs_data)

View file

@ -12,4 +12,4 @@
max create mode
python.ExecuteFile startup
)
)

View file

@ -0,0 +1,305 @@
import json
import logging
import os
from maya import cmds # noqa
from ayon_core.hosts.maya.api.lib import evaluation
log = logging.getLogger(__name__)
# The maya alembic export types
ALEMBIC_ARGS = {
"attr": (list, tuple),
"attrPrefix": (list, tuple),
"autoSubd": bool,
"dataFormat": str,
"endFrame": float,
"eulerFilter": bool,
"frameRange": str, # "start end"; overrides startFrame & endFrame
"frameRelativeSample": float,
"melPerFrameCallback": str,
"melPostJobCallback": str,
"noNormals": bool,
"preRoll": bool,
"preRollStartFrame": int,
"pythonPerFrameCallback": str,
"pythonPostJobCallback": str,
"renderableOnly": bool,
"root": (list, tuple),
"selection": bool,
"startFrame": float,
"step": float,
"stripNamespaces": bool,
"userAttr": (list, tuple),
"userAttrPrefix": (list, tuple),
"uvWrite": bool,
"uvsOnly": bool,
"verbose": bool,
"wholeFrameGeo": bool,
"worldSpace": bool,
"writeColorSets": bool,
"writeCreases": bool, # Maya 2015 Ext1+
"writeFaceSets": bool,
"writeUVSets": bool, # Maya 2017+
"writeVisibility": bool,
}
def extract_alembic(
file,
attr=None,
attrPrefix=None,
dataFormat="ogawa",
endFrame=None,
eulerFilter=True,
frameRange="",
noNormals=False,
preRoll=False,
preRollStartFrame=0,
renderableOnly=False,
root=None,
selection=True,
startFrame=None,
step=1.0,
stripNamespaces=True,
uvWrite=True,
verbose=False,
wholeFrameGeo=False,
worldSpace=False,
writeColorSets=False,
writeCreases=False,
writeFaceSets=False,
writeUVSets=False,
writeVisibility=False
):
"""Extract a single Alembic Cache.
This extracts an Alembic cache using the `-selection` flag to minimize
the extracted content to solely what was Collected into the instance.
Arguments:
file (str): The filepath to write the alembic file to.
attr (list of str, optional): A specific geometric attribute to write
out. Defaults to [].
attrPrefix (list of str, optional): Prefix filter for determining which
geometric attributes to write out. Defaults to ["ABC_"].
dataFormat (str): The data format to use for the cache,
defaults to "ogawa"
endFrame (float): End frame of output. Ignored if `frameRange`
provided.
eulerFilter (bool): When on, X, Y, and Z rotation data is filtered with
an Euler filter. Euler filtering helps resolve irregularities in
rotations especially if X, Y, and Z rotations exceed 360 degrees.
Defaults to True.
frameRange (tuple or str): Two-tuple with start and end frame or a
string formatted as: "startFrame endFrame". This argument
overrides `startFrame` and `endFrame` arguments.
noNormals (bool): When on, normal data from the original polygon
objects is not included in the exported Alembic cache file.
preRoll (bool): This frame range will not be sampled.
Defaults to False.
preRollStartFrame (float): The frame to start scene
evaluation at. This is used to set the starting frame for time
dependent translations and can be used to evaluate run-up that
isn't actually translated. Defaults to 0.
renderableOnly (bool): When on, any non-renderable nodes or hierarchy,
such as hidden objects, are not included in the Alembic file.
Defaults to False.
root (list of str): Maya dag path which will be parented to
the root of the Alembic file. Defaults to [], which means the
entire scene will be written out.
selection (bool): Write out all all selected nodes from the
active selection list that are descendents of the roots specified
with -root. Defaults to False.
startFrame (float): Start frame of output. Ignored if `frameRange`
provided.
step (float): The time interval (expressed in frames) at
which the frame range is sampled. Additional samples around each
frame can be specified with -frs. Defaults to 1.0.
stripNamespaces (bool): When on, any namespaces associated with the
exported objects are removed from the Alembic file. For example, an
object with the namespace taco:foo:bar appears as bar in the
Alembic file.
uvWrite (bool): When on, UV data from polygon meshes and subdivision
objects are written to the Alembic file. Only the current UV map is
included.
verbose (bool): When on, outputs frame number information to the
Script Editor or output window during extraction.
wholeFrameGeo (bool): Data for geometry will only be written
out on whole frames. Defaults to False.
worldSpace (bool): When on, the top node in the node hierarchy is
stored as world space. By default, these nodes are stored as local
space. Defaults to False.
writeColorSets (bool): Write all color sets on MFnMeshes as
color 3 or color 4 indexed geometry parameters with face varying
scope. Defaults to False.
writeCreases (bool): If the mesh has crease edges or crease
vertices, the mesh (OPolyMesh) would now be written out as an OSubD
and crease info will be stored in the Alembic file. Otherwise,
creases info won't be preserved in Alembic file unless a custom
Boolean attribute SubDivisionMesh has been added to mesh node and
its value is true. Defaults to False.
writeFaceSets (bool): Write all Face sets on MFnMeshes.
Defaults to False.
writeUVSets (bool): Write all uv sets on MFnMeshes as vector
2 indexed geometry parameters with face varying scope. Defaults to
False.
writeVisibility (bool): Visibility state will be stored in
the Alembic file. Otherwise everything written out is treated as
visible. Defaults to False.
"""
# Ensure alembic exporter is loaded
cmds.loadPlugin('AbcExport', quiet=True)
# Alembic Exporter requires forward slashes
file = file.replace('\\', '/')
# Ensure list arguments are valid.
attr = attr or []
attrPrefix = attrPrefix or []
root = root or []
# Pass the start and end frame on as `frameRange` so that it
# never conflicts with that argument
if not frameRange:
# Fallback to maya timeline if no start or end frame provided.
if startFrame is None:
startFrame = cmds.playbackOptions(query=True,
animationStartTime=True)
if endFrame is None:
endFrame = cmds.playbackOptions(query=True,
animationEndTime=True)
# Ensure valid types are converted to frame range
assert isinstance(startFrame, ALEMBIC_ARGS["startFrame"])
assert isinstance(endFrame, ALEMBIC_ARGS["endFrame"])
frameRange = "{0} {1}".format(startFrame, endFrame)
else:
# Allow conversion from tuple for `frameRange`
if isinstance(frameRange, (list, tuple)):
assert len(frameRange) == 2
frameRange = "{0} {1}".format(frameRange[0], frameRange[1])
# Assemble options
options = {
"selection": selection,
"frameRange": frameRange,
"eulerFilter": eulerFilter,
"noNormals": noNormals,
"preRoll": preRoll,
"renderableOnly": renderableOnly,
"uvWrite": uvWrite,
"writeColorSets": writeColorSets,
"writeFaceSets": writeFaceSets,
"wholeFrameGeo": wholeFrameGeo,
"worldSpace": worldSpace,
"writeVisibility": writeVisibility,
"writeUVSets": writeUVSets,
"writeCreases": writeCreases,
"dataFormat": dataFormat,
"step": step,
"attr": attr,
"attrPrefix": attrPrefix,
"stripNamespaces": stripNamespaces,
"verbose": verbose,
"preRollStartFrame": preRollStartFrame
}
# Validate options
for key, value in options.copy().items():
# Discard unknown options
if key not in ALEMBIC_ARGS:
log.warning("extract_alembic() does not support option '%s'. "
"Flag will be ignored..", key)
options.pop(key)
continue
# Validate value type
valid_types = ALEMBIC_ARGS[key]
if not isinstance(value, valid_types):
raise TypeError("Alembic option unsupported type: "
"{0} (expected {1})".format(value, valid_types))
# Ignore empty values, like an empty string, since they mess up how
# job arguments are built
if isinstance(value, (list, tuple)):
value = [x for x in value if x.strip()]
# Ignore option completely if no values remaining
if not value:
options.pop(key)
continue
options[key] = value
# The `writeCreases` argument was changed to `autoSubd` in Maya 2018+
maya_version = int(cmds.about(version=True))
if maya_version >= 2018:
options['autoSubd'] = options.pop('writeCreases', False)
# Format the job string from options
job_args = list()
for key, value in options.items():
if isinstance(value, (list, tuple)):
for entry in value:
job_args.append("-{} {}".format(key, entry))
elif isinstance(value, bool):
# Add only when state is set to True
if value:
job_args.append("-{0}".format(key))
else:
job_args.append("-{0} {1}".format(key, value))
job_str = " ".join(job_args)
job_str += ' -file "%s"' % file
# Ensure output directory exists
parent_dir = os.path.dirname(file)
if not os.path.exists(parent_dir):
os.makedirs(parent_dir)
if verbose:
log.debug("Preparing Alembic export with options: %s",
json.dumps(options, indent=4))
log.debug("Extracting Alembic with job arguments: %s", job_str)
# Perform extraction
print("Alembic Job Arguments : {}".format(job_str))
# Disable the parallel evaluation temporarily to ensure no buggy
# exports are made. (PLN-31)
# TODO: Make sure this actually fixes the issues
with evaluation("off"):
cmds.AbcExport(j=job_str, verbose=verbose)
if verbose:
log.debug("Extracted Alembic to: %s", file)
return file

View file

@ -70,37 +70,6 @@ DEFAULT_MATRIX = [1.0, 0.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0]
# The maya alembic export types
_alembic_options = {
"startFrame": float,
"endFrame": float,
"frameRange": str, # "start end"; overrides startFrame & endFrame
"eulerFilter": bool,
"frameRelativeSample": float,
"noNormals": bool,
"renderableOnly": bool,
"step": float,
"stripNamespaces": bool,
"uvWrite": bool,
"wholeFrameGeo": bool,
"worldSpace": bool,
"writeVisibility": bool,
"writeColorSets": bool,
"writeFaceSets": bool,
"writeCreases": bool, # Maya 2015 Ext1+
"writeUVSets": bool, # Maya 2017+
"dataFormat": str,
"root": (list, tuple),
"attr": (list, tuple),
"attrPrefix": (list, tuple),
"userAttr": (list, tuple),
"melPerFrameCallback": str,
"melPostJobCallback": str,
"pythonPerFrameCallback": str,
"pythonPostJobCallback": str,
"selection": bool
}
INT_FPS = {15, 24, 25, 30, 48, 50, 60, 44100, 48000}
FLOAT_FPS = {23.98, 23.976, 29.97, 47.952, 59.94}
@ -1346,178 +1315,6 @@ def is_visible(node,
return True
def extract_alembic(file,
startFrame=None,
endFrame=None,
selection=True,
uvWrite=True,
eulerFilter=True,
dataFormat="ogawa",
verbose=False,
**kwargs):
"""Extract a single Alembic Cache.
This extracts an Alembic cache using the `-selection` flag to minimize
the extracted content to solely what was Collected into the instance.
Arguments:
startFrame (float): Start frame of output. Ignored if `frameRange`
provided.
endFrame (float): End frame of output. Ignored if `frameRange`
provided.
frameRange (tuple or str): Two-tuple with start and end frame or a
string formatted as: "startFrame endFrame". This argument
overrides `startFrame` and `endFrame` arguments.
dataFormat (str): The data format to use for the cache,
defaults to "ogawa"
verbose (bool): When on, outputs frame number information to the
Script Editor or output window during extraction.
noNormals (bool): When on, normal data from the original polygon
objects is not included in the exported Alembic cache file.
renderableOnly (bool): When on, any non-renderable nodes or hierarchy,
such as hidden objects, are not included in the Alembic file.
Defaults to False.
stripNamespaces (bool): When on, any namespaces associated with the
exported objects are removed from the Alembic file. For example, an
object with the namespace taco:foo:bar appears as bar in the
Alembic file.
uvWrite (bool): When on, UV data from polygon meshes and subdivision
objects are written to the Alembic file. Only the current UV map is
included.
worldSpace (bool): When on, the top node in the node hierarchy is
stored as world space. By default, these nodes are stored as local
space. Defaults to False.
eulerFilter (bool): When on, X, Y, and Z rotation data is filtered with
an Euler filter. Euler filtering helps resolve irregularities in
rotations especially if X, Y, and Z rotations exceed 360 degrees.
Defaults to True.
"""
# Ensure alembic exporter is loaded
cmds.loadPlugin('AbcExport', quiet=True)
# Alembic Exporter requires forward slashes
file = file.replace('\\', '/')
# Pass the start and end frame on as `frameRange` so that it
# never conflicts with that argument
if "frameRange" not in kwargs:
# Fallback to maya timeline if no start or end frame provided.
if startFrame is None:
startFrame = cmds.playbackOptions(query=True,
animationStartTime=True)
if endFrame is None:
endFrame = cmds.playbackOptions(query=True,
animationEndTime=True)
# Ensure valid types are converted to frame range
assert isinstance(startFrame, _alembic_options["startFrame"])
assert isinstance(endFrame, _alembic_options["endFrame"])
kwargs["frameRange"] = "{0} {1}".format(startFrame, endFrame)
else:
# Allow conversion from tuple for `frameRange`
frame_range = kwargs["frameRange"]
if isinstance(frame_range, (list, tuple)):
assert len(frame_range) == 2
kwargs["frameRange"] = "{0} {1}".format(frame_range[0],
frame_range[1])
# Assemble options
options = {
"selection": selection,
"uvWrite": uvWrite,
"eulerFilter": eulerFilter,
"dataFormat": dataFormat
}
options.update(kwargs)
# Validate options
for key, value in options.copy().items():
# Discard unknown options
if key not in _alembic_options:
log.warning("extract_alembic() does not support option '%s'. "
"Flag will be ignored..", key)
options.pop(key)
continue
# Validate value type
valid_types = _alembic_options[key]
if not isinstance(value, valid_types):
raise TypeError("Alembic option unsupported type: "
"{0} (expected {1})".format(value, valid_types))
# Ignore empty values, like an empty string, since they mess up how
# job arguments are built
if isinstance(value, (list, tuple)):
value = [x for x in value if x.strip()]
# Ignore option completely if no values remaining
if not value:
options.pop(key)
continue
options[key] = value
# The `writeCreases` argument was changed to `autoSubd` in Maya 2018+
maya_version = int(cmds.about(version=True))
if maya_version >= 2018:
options['autoSubd'] = options.pop('writeCreases', False)
# Format the job string from options
job_args = list()
for key, value in options.items():
if isinstance(value, (list, tuple)):
for entry in value:
job_args.append("-{} {}".format(key, entry))
elif isinstance(value, bool):
# Add only when state is set to True
if value:
job_args.append("-{0}".format(key))
else:
job_args.append("-{0} {1}".format(key, value))
job_str = " ".join(job_args)
job_str += ' -file "%s"' % file
# Ensure output directory exists
parent_dir = os.path.dirname(file)
if not os.path.exists(parent_dir):
os.makedirs(parent_dir)
if verbose:
log.debug("Preparing Alembic export with options: %s",
json.dumps(options, indent=4))
log.debug("Extracting Alembic with job arguments: %s", job_str)
# Perform extraction
print("Alembic Job Arguments : {}".format(job_str))
# Disable the parallel evaluation temporarily to ensure no buggy
# exports are made. (PLN-31)
# TODO: Make sure this actually fixes the issues
with evaluation("off"):
cmds.AbcExport(j=job_str, verbose=verbose)
if verbose:
log.debug("Extracted Alembic to: %s", file)
return file
# region ID
def get_id_required_nodes(referenced_nodes=False,
nodes=None,
@ -2520,7 +2317,16 @@ def set_scene_fps(fps, update=True):
"""
fps_mapping = {
'2': '2fps',
'3': '3fps',
'4': '4fps',
'5': '5fps',
'6': '6fps',
'8': '8fps',
'10': '10fps',
'12': '12fps',
'15': 'game',
'16': '16fps',
'24': 'film',
'25': 'pal',
'30': 'ntsc',
@ -2612,21 +2418,24 @@ def get_fps_for_current_context():
Returns:
Union[int, float]: FPS value.
"""
project_name = get_current_project_name()
folder_path = get_current_folder_path()
folder_entity = ayon_api.get_folder_by_path(
project_name, folder_path, fields={"attrib.fps"}
) or {}
fps = folder_entity.get("attrib", {}).get("fps")
task_entity = get_current_task_entity(fields={"attrib"})
fps = task_entity.get("attrib", {}).get("fps")
if not fps:
project_entity = ayon_api.get_project(
project_name, fields=["attrib.fps"]
project_name = get_current_project_name()
folder_path = get_current_folder_path()
folder_entity = ayon_api.get_folder_by_path(
project_name, folder_path, fields={"attrib.fps"}
) or {}
fps = project_entity.get("attrib", {}).get("fps")
fps = folder_entity.get("attrib", {}).get("fps")
if not fps:
fps = 25
project_entity = ayon_api.get_project(
project_name, fields=["attrib.fps"]
) or {}
fps = project_entity.get("attrib", {}).get("fps")
if not fps:
fps = 25
return convert_to_maya_fps(fps)

View file

@ -720,7 +720,8 @@ class RenderProductsArnold(ARenderProducts):
# AOVs > Legacy > Maya Render View > Mode
aovs_enabled = bool(
self._get_attr("defaultArnoldRenderOptions.aovMode")
self._get_attr(
"defaultArnoldRenderOptions.aovMode", as_string=False)
)
if not aovs_enabled:
return beauty_products

View file

@ -30,9 +30,11 @@ from ayon_core.pipeline import (
register_loader_plugin_path,
register_inventory_action_path,
register_creator_plugin_path,
register_workfile_build_plugin_path,
deregister_loader_plugin_path,
deregister_inventory_action_path,
deregister_creator_plugin_path,
deregister_workfile_build_plugin_path,
AYON_CONTAINER_ID,
AVALON_CONTAINER_ID,
)
@ -47,7 +49,6 @@ from ayon_core.hosts.maya import MAYA_ROOT_DIR
from ayon_core.hosts.maya.lib import create_workspace_mel
from . import menu, lib
from .workfile_template_builder import MayaPlaceholderLoadPlugin
from .workio import (
open_file,
save_file,
@ -64,6 +65,7 @@ PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
WORKFILE_BUILD_PATH = os.path.join(PLUGINS_DIR, "workfile_build")
AVALON_CONTAINERS = ":AVALON_CONTAINERS"
@ -93,7 +95,7 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
register_loader_plugin_path(LOAD_PATH)
register_creator_plugin_path(CREATE_PATH)
register_inventory_action_path(INVENTORY_PATH)
self.log.info(PUBLISH_PATH)
register_workfile_build_plugin_path(WORKFILE_BUILD_PATH)
self.log.info("Installing callbacks ... ")
register_event_callback("init", on_init)
@ -148,11 +150,6 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
def get_containers(self):
return ls()
def get_workfile_build_placeholder_plugins(self):
return [
MayaPlaceholderLoadPlugin
]
@contextlib.contextmanager
def maintained_selection(self):
with lib.maintained_selection():
@ -338,6 +335,7 @@ def uninstall():
deregister_loader_plugin_path(LOAD_PATH)
deregister_creator_plugin_path(CREATE_PATH)
deregister_inventory_action_path(INVENTORY_PATH)
deregister_workfile_build_plugin_path(WORKFILE_BUILD_PATH)
menu.uninstall()

View file

@ -19,7 +19,7 @@ from .lib import pairwise
@contextlib.contextmanager
def _allow_export_from_render_setup_layer():
def allow_export_from_render_setup_layer():
"""Context manager to override Maya settings to allow RS layer export"""
try:
@ -102,7 +102,7 @@ def export_in_rs_layer(path, nodes, export=None):
cmds.disconnectAttr(src, dest)
# Export Selected
with _allow_export_from_render_setup_layer():
with allow_export_from_render_setup_layer():
cmds.select(nodes, noExpand=True)
if export:
export()

View file

@ -1,5 +1,3 @@
import json
from maya import cmds
from ayon_core.pipeline import (
@ -10,16 +8,13 @@ from ayon_core.pipeline import (
)
from ayon_core.pipeline.workfile.workfile_template_builder import (
TemplateAlreadyImported,
AbstractTemplateBuilder,
PlaceholderPlugin,
LoadPlaceholderItem,
PlaceholderLoadMixin,
AbstractTemplateBuilder
)
from ayon_core.tools.workfile_template_build import (
WorkfileBuildPlaceholderDialog,
)
from .lib import read, imprint, get_reference_node, get_main_window
from .lib import get_main_window
PLACEHOLDER_SET = "PLACEHOLDERS_SET"
@ -91,255 +86,6 @@ class MayaTemplateBuilder(AbstractTemplateBuilder):
return True
class MayaPlaceholderLoadPlugin(PlaceholderPlugin, PlaceholderLoadMixin):
identifier = "maya.load"
label = "Maya load"
def _collect_scene_placeholders(self):
# Cache placeholder data to shared data
placeholder_nodes = self.builder.get_shared_populate_data(
"placeholder_nodes"
)
if placeholder_nodes is None:
attributes = cmds.ls("*.plugin_identifier", long=True)
placeholder_nodes = {}
for attribute in attributes:
node_name = attribute.rpartition(".")[0]
placeholder_nodes[node_name] = (
self._parse_placeholder_node_data(node_name)
)
self.builder.set_shared_populate_data(
"placeholder_nodes", placeholder_nodes
)
return placeholder_nodes
def _parse_placeholder_node_data(self, node_name):
placeholder_data = read(node_name)
parent_name = (
cmds.getAttr(node_name + ".parent", asString=True)
or node_name.rpartition("|")[0]
or ""
)
if parent_name:
siblings = cmds.listRelatives(parent_name, children=True)
else:
siblings = cmds.ls(assemblies=True)
node_shortname = node_name.rpartition("|")[2]
current_index = cmds.getAttr(node_name + ".index", asString=True)
if current_index < 0:
current_index = siblings.index(node_shortname)
placeholder_data.update({
"parent": parent_name,
"index": current_index
})
return placeholder_data
def _create_placeholder_name(self, placeholder_data):
placeholder_name_parts = placeholder_data["builder_type"].split("_")
pos = 1
placeholder_product_type = placeholder_data.get("product_type")
if placeholder_product_type is None:
placeholder_product_type = placeholder_data.get("family")
if placeholder_product_type:
placeholder_name_parts.insert(pos, placeholder_product_type)
pos += 1
# add loader arguments if any
loader_args = placeholder_data["loader_args"]
if loader_args:
loader_args = json.loads(loader_args.replace('\'', '\"'))
values = [v for v in loader_args.values()]
for value in values:
placeholder_name_parts.insert(pos, value)
pos += 1
placeholder_name = "_".join(placeholder_name_parts)
return placeholder_name.capitalize()
def _get_loaded_repre_ids(self):
loaded_representation_ids = self.builder.get_shared_populate_data(
"loaded_representation_ids"
)
if loaded_representation_ids is None:
try:
containers = cmds.sets("AVALON_CONTAINERS", q=True)
except ValueError:
containers = []
loaded_representation_ids = {
cmds.getAttr(container + ".representation")
for container in containers
}
self.builder.set_shared_populate_data(
"loaded_representation_ids", loaded_representation_ids
)
return loaded_representation_ids
def create_placeholder(self, placeholder_data):
selection = cmds.ls(selection=True)
if len(selection) > 1:
raise ValueError("More then one item are selected")
parent = selection[0] if selection else None
placeholder_data["plugin_identifier"] = self.identifier
placeholder_name = self._create_placeholder_name(placeholder_data)
placeholder = cmds.spaceLocator(name=placeholder_name)[0]
if parent:
placeholder = cmds.parent(placeholder, selection[0])[0]
imprint(placeholder, placeholder_data)
# Add helper attributes to keep placeholder info
cmds.addAttr(
placeholder,
longName="parent",
hidden=True,
dataType="string"
)
cmds.addAttr(
placeholder,
longName="index",
hidden=True,
attributeType="short",
defaultValue=-1
)
cmds.setAttr(placeholder + ".parent", "", type="string")
def update_placeholder(self, placeholder_item, placeholder_data):
node_name = placeholder_item.scene_identifier
new_values = {}
for key, value in placeholder_data.items():
placeholder_value = placeholder_item.data.get(key)
if value != placeholder_value:
new_values[key] = value
placeholder_item.data[key] = value
for key in new_values.keys():
cmds.deleteAttr(node_name + "." + key)
imprint(node_name, new_values)
def collect_placeholders(self):
output = []
scene_placeholders = self._collect_scene_placeholders()
for node_name, placeholder_data in scene_placeholders.items():
if placeholder_data.get("plugin_identifier") != self.identifier:
continue
# TODO do data validations and maybe upgrades if they are invalid
output.append(
LoadPlaceholderItem(node_name, placeholder_data, self)
)
return output
def populate_placeholder(self, placeholder):
self.populate_load_placeholder(placeholder)
def repopulate_placeholder(self, placeholder):
repre_ids = self._get_loaded_repre_ids()
self.populate_load_placeholder(placeholder, repre_ids)
def get_placeholder_options(self, options=None):
return self.get_load_plugin_options(options)
def post_placeholder_process(self, placeholder, failed):
"""Cleanup placeholder after load of its corresponding representations.
Args:
placeholder (PlaceholderItem): Item which was just used to load
representation.
failed (bool): Loading of representation failed.
"""
# Hide placeholder and add them to placeholder set
node = placeholder.scene_identifier
cmds.sets(node, addElement=PLACEHOLDER_SET)
cmds.hide(node)
cmds.setAttr(node + ".hiddenInOutliner", True)
def delete_placeholder(self, placeholder):
"""Remove placeholder if building was successful"""
cmds.delete(placeholder.scene_identifier)
def load_succeed(self, placeholder, container):
self._parent_in_hierarchy(placeholder, container)
def _parent_in_hierarchy(self, placeholder, container):
"""Parent loaded container to placeholder's parent.
ie : Set loaded content as placeholder's sibling
Args:
container (str): Placeholder loaded containers
"""
if not container:
return
roots = cmds.sets(container, q=True) or []
ref_node = None
try:
ref_node = get_reference_node(roots)
except AssertionError as e:
self.log.info(e.args[0])
nodes_to_parent = []
for root in roots:
if ref_node:
ref_root = cmds.referenceQuery(root, nodes=True)[0]
ref_root = (
cmds.listRelatives(ref_root, parent=True, path=True) or
[ref_root]
)
nodes_to_parent.extend(ref_root)
continue
if root.endswith("_RN"):
# Backwards compatibility for hardcoded reference names.
refRoot = cmds.referenceQuery(root, n=True)[0]
refRoot = cmds.listRelatives(refRoot, parent=True) or [refRoot]
nodes_to_parent.extend(refRoot)
elif root not in cmds.listSets(allSets=True):
nodes_to_parent.append(root)
elif not cmds.sets(root, q=True):
return
# Move loaded nodes to correct index in outliner hierarchy
placeholder_form = cmds.xform(
placeholder.scene_identifier,
q=True,
matrix=True,
worldSpace=True
)
scene_parent = cmds.listRelatives(
placeholder.scene_identifier, parent=True, fullPath=True
)
for node in set(nodes_to_parent):
cmds.reorder(node, front=True)
cmds.reorder(node, relative=placeholder.data["index"])
cmds.xform(node, matrix=placeholder_form, ws=True)
if scene_parent:
cmds.parent(node, scene_parent)
else:
cmds.parent(node, world=True)
holding_sets = cmds.listSets(object=placeholder.scene_identifier)
if not holding_sets:
return
for holding_set in holding_sets:
cmds.sets(roots, forceElement=holding_set)
def build_workfile_template(*args):
builder = MayaTemplateBuilder(registered_host())
builder.build_template()

View file

@ -0,0 +1,101 @@
from typing import List
from maya import cmds
def get_yeti_user_variables(yeti_shape_node: str) -> List[str]:
"""Get user defined yeti user variables for a `pgYetiMaya` shape node.
Arguments:
yeti_shape_node (str): The `pgYetiMaya` shape node.
Returns:
list: Attribute names (for a vector attribute it only lists the top
parent attribute, not the attribute per axis)
"""
attrs = cmds.listAttr(yeti_shape_node,
userDefined=True,
string=("yetiVariableV_*",
"yetiVariableF_*")) or []
valid_attrs = []
for attr in attrs:
attr_type = cmds.attributeQuery(attr, node=yeti_shape_node,
attributeType=True)
if attr.startswith("yetiVariableV_") and attr_type == "double3":
# vector
valid_attrs.append(attr)
elif attr.startswith("yetiVariableF_") and attr_type == "double":
valid_attrs.append(attr)
return valid_attrs
def create_yeti_variable(yeti_shape_node: str,
attr_name: str,
value=None,
force_value: bool = False) -> bool:
"""Get user defined yeti user variables for a `pgYetiMaya` shape node.
Arguments:
yeti_shape_node (str): The `pgYetiMaya` shape node.
attr_name (str): The fully qualified yeti variable name, e.g.
"yetiVariableF_myfloat" or "yetiVariableV_myvector"
value (object): The value to set (must match the type of the attribute)
When value is None it will ignored and not be set.
force_value (bool): Whether to set the value if the attribute already
exists or not.
Returns:
bool: Whether the attribute value was set or not.
"""
exists = cmds.attributeQuery(attr_name, node=yeti_shape_node, exists=True)
if not exists:
if attr_name.startswith("yetiVariableV_"):
_create_vector_yeti_user_variable(yeti_shape_node, attr_name)
if attr_name.startswith("yetiVariableF_"):
_create_float_yeti_user_variable(yeti_shape_node, attr_name)
if value is not None and (not exists or force_value):
plug = "{}.{}".format(yeti_shape_node, attr_name)
if (
isinstance(value, (list, tuple))
and attr_name.startswith("yetiVariableV_")
):
cmds.setAttr(plug, *value, type="double3")
else:
cmds.setAttr(plug, value)
return True
return False
def _create_vector_yeti_user_variable(yeti_shape_node: str, attr_name: str):
if not attr_name.startswith("yetiVariableV_"):
raise ValueError("Must start with yetiVariableV_")
cmds.addAttr(yeti_shape_node,
longName=attr_name,
attributeType="double3",
cachedInternally=True,
keyable=True)
for axis in "XYZ":
cmds.addAttr(yeti_shape_node,
longName="{}{}".format(attr_name, axis),
attributeType="double",
parent=attr_name,
cachedInternally=True,
keyable=True)
def _create_float_yeti_user_variable(yeti_node: str, attr_name: str):
if not attr_name.startswith("yetiVariableF_"):
raise ValueError("Must start with yetiVariableF_")
cmds.addAttr(yeti_node,
longName=attr_name,
attributeType="double",
cachedInternally=True,
softMinValue=0,
softMaxValue=100,
keyable=True)

View file

@ -1,89 +0,0 @@
from ayon_core.hosts.maya.api import (
lib,
plugin
)
from ayon_core.lib import (
BoolDef,
TextDef
)
class CreateAnimation(plugin.MayaHiddenCreator):
"""Animation output for character rigs
We hide the animation creator from the UI since the creation of it is
automated upon loading a rig. There's an inventory action to recreate it
for loaded rigs if by chance someone deleted the animation instance.
"""
identifier = "io.openpype.creators.maya.animation"
name = "animationDefault"
label = "Animation"
product_type = "animation"
icon = "male"
write_color_sets = False
write_face_sets = False
include_parent_hierarchy = False
include_user_defined_attributes = False
def get_instance_attr_defs(self):
defs = lib.collect_animation_defs()
defs.extend([
BoolDef("writeColorSets",
label="Write vertex colors",
tooltip="Write vertex colors with the geometry",
default=self.write_color_sets),
BoolDef("writeFaceSets",
label="Write face sets",
tooltip="Write face sets with the geometry",
default=self.write_face_sets),
BoolDef("writeNormals",
label="Write normals",
tooltip="Write normals with the deforming geometry",
default=True),
BoolDef("renderableOnly",
label="Renderable Only",
tooltip="Only export renderable visible shapes",
default=False),
BoolDef("visibleOnly",
label="Visible Only",
tooltip="Only export dag objects visible during "
"frame range",
default=False),
BoolDef("includeParentHierarchy",
label="Include Parent Hierarchy",
tooltip="Whether to include parent hierarchy of nodes in "
"the publish instance",
default=self.include_parent_hierarchy),
BoolDef("worldSpace",
label="World-Space Export",
default=True),
BoolDef("includeUserDefinedAttributes",
label="Include User Defined Attributes",
default=self.include_user_defined_attributes),
TextDef("attr",
label="Custom Attributes",
default="",
placeholder="attr1, attr2"),
TextDef("attrPrefix",
label="Custom Attributes Prefix",
placeholder="prefix1, prefix2")
])
# TODO: Implement these on a Deadline plug-in instead?
"""
# Default to not send to farm.
self.data["farm"] = False
self.data["priority"] = 50
"""
return defs
def apply_settings(self, project_settings):
super(CreateAnimation, self).apply_settings(project_settings)
# Hardcoding creator to be enabled due to existing settings would
# disable the creator causing the creator plugin to not be
# discoverable.
self.enabled = True

View file

@ -0,0 +1,139 @@
from maya import cmds
from ayon_core.hosts.maya.api import lib, plugin
from ayon_core.lib import (
BoolDef,
NumberDef,
)
from ayon_core.pipeline import CreatedInstance
def _get_animation_attr_defs(cls):
"""Get Animation generic definitions."""
defs = lib.collect_animation_defs()
defs.extend(
[
BoolDef("farm", label="Submit to Farm"),
NumberDef("priority", label="Farm job Priority", default=50),
BoolDef("refresh", label="Refresh viewport during export"),
BoolDef(
"includeParentHierarchy",
label="Include Parent Hierarchy",
tooltip=(
"Whether to include parent hierarchy of nodes in the "
"publish instance."
)
),
BoolDef(
"includeUserDefinedAttributes",
label="Include User Defined Attributes",
tooltip=(
"Whether to include all custom maya attributes found "
"on nodes as attributes in the Alembic data."
)
),
]
)
return defs
def convert_legacy_alembic_creator_attributes(node_data, class_name):
"""This is a legacy transfer of creator attributes to publish attributes
for ExtractAlembic/ExtractAnimation plugin.
"""
publish_attributes = node_data["publish_attributes"]
if class_name in publish_attributes:
return node_data
attributes = [
"attr",
"attrPrefix",
"visibleOnly",
"writeColorSets",
"writeFaceSets",
"writeNormals",
"renderableOnly",
"visibleOnly",
"worldSpace",
"renderableOnly"
]
plugin_attributes = {}
for attr in attributes:
if attr not in node_data["creator_attributes"]:
continue
value = node_data["creator_attributes"].pop(attr)
plugin_attributes[attr] = value
publish_attributes[class_name] = plugin_attributes
return node_data
class CreateAnimation(plugin.MayaHiddenCreator):
"""Animation output for character rigs
We hide the animation creator from the UI since the creation of it is
automated upon loading a rig. There's an inventory action to recreate it
for loaded rigs if by chance someone deleted the animation instance.
"""
identifier = "io.openpype.creators.maya.animation"
name = "animationDefault"
label = "Animation"
product_type = "animation"
icon = "male"
write_color_sets = False
write_face_sets = False
include_parent_hierarchy = False
include_user_defined_attributes = False
def read_instance_node(self, node):
node_data = super(CreateAnimation, self).read_instance_node(node)
node_data = convert_legacy_alembic_creator_attributes(
node_data, "ExtractAnimation"
)
return node_data
def get_instance_attr_defs(self):
defs = super(CreateAnimation, self).get_instance_attr_defs()
defs += _get_animation_attr_defs(self)
return defs
class CreatePointCache(plugin.MayaCreator):
"""Alembic pointcache for animated data"""
identifier = "io.openpype.creators.maya.pointcache"
label = "Pointcache"
product_type = "pointcache"
icon = "gears"
write_color_sets = False
write_face_sets = False
include_user_defined_attributes = False
def read_instance_node(self, node):
node_data = super(CreatePointCache, self).read_instance_node(node)
node_data = convert_legacy_alembic_creator_attributes(
node_data, "ExtractAlembic"
)
return node_data
def get_instance_attr_defs(self):
defs = super(CreatePointCache, self).get_instance_attr_defs()
defs += _get_animation_attr_defs(self)
return defs
def create(self, product_name, instance_data, pre_create_data):
instance = super(CreatePointCache, self).create(
product_name, instance_data, pre_create_data
)
instance_node = instance.get("instance_node")
# For Arnold standin proxy
proxy_set = cmds.sets(name=instance_node + "_proxy_SET", empty=True)
cmds.sets(proxy_set, forceElement=instance_node)

View file

@ -1,88 +0,0 @@
from maya import cmds
from ayon_core.hosts.maya.api import (
lib,
plugin
)
from ayon_core.lib import (
BoolDef,
TextDef
)
class CreatePointCache(plugin.MayaCreator):
"""Alembic pointcache for animated data"""
identifier = "io.openpype.creators.maya.pointcache"
label = "Pointcache"
product_type = "pointcache"
icon = "gears"
write_color_sets = False
write_face_sets = False
include_user_defined_attributes = False
def get_instance_attr_defs(self):
defs = lib.collect_animation_defs()
defs.extend([
BoolDef("writeColorSets",
label="Write vertex colors",
tooltip="Write vertex colors with the geometry",
default=False),
BoolDef("writeFaceSets",
label="Write face sets",
tooltip="Write face sets with the geometry",
default=False),
BoolDef("renderableOnly",
label="Renderable Only",
tooltip="Only export renderable visible shapes",
default=False),
BoolDef("visibleOnly",
label="Visible Only",
tooltip="Only export dag objects visible during "
"frame range",
default=False),
BoolDef("includeParentHierarchy",
label="Include Parent Hierarchy",
tooltip="Whether to include parent hierarchy of nodes in "
"the publish instance",
default=False),
BoolDef("worldSpace",
label="World-Space Export",
default=True),
BoolDef("refresh",
label="Refresh viewport during export",
default=False),
BoolDef("includeUserDefinedAttributes",
label="Include User Defined Attributes",
default=self.include_user_defined_attributes),
TextDef("attr",
label="Custom Attributes",
default="",
placeholder="attr1, attr2"),
TextDef("attrPrefix",
label="Custom Attributes Prefix",
default="",
placeholder="prefix1, prefix2")
])
# TODO: Implement these on a Deadline plug-in instead?
"""
# Default to not send to farm.
self.data["farm"] = False
self.data["priority"] = 50
"""
return defs
def create(self, product_name, instance_data, pre_create_data):
instance = super(CreatePointCache, self).create(
product_name, instance_data, pre_create_data
)
instance_node = instance.get("instance_node")
# For Arnold standin proxy
proxy_set = cmds.sets(name=instance_node + "_proxy_SET", empty=True)
cmds.sets(proxy_set, forceElement=instance_node)

View file

@ -37,7 +37,7 @@ class ConnectGeometry(InventoryAction):
repre_id = container["representation"]
repre_context = repre_contexts_by_id[repre_id]
product_type = repre_context["prouct"]["productType"]
product_type = repre_context["product"]["productType"]
containers_by_product_type.setdefault(product_type, [])
containers_by_product_type[product_type].append(container)

View file

@ -36,7 +36,7 @@ class ConnectXgen(InventoryAction):
repre_id = container["representation"]
repre_context = repre_contexts_by_id[repre_id]
product_type = repre_context["prouct"]["productType"]
product_type = repre_context["product"]["productType"]
containers_by_product_type.setdefault(product_type, [])
containers_by_product_type[product_type].append(container)

View file

@ -39,7 +39,7 @@ class ConnectYetiRig(InventoryAction):
repre_id = container["representation"]
repre_context = repre_contexts_by_id[repre_id]
product_type = repre_context["prouct"]["productType"]
product_type = repre_context["product"]["productType"]
containers_by_product_type.setdefault(product_type, [])
containers_by_product_type[product_type].append(container)

View file

@ -0,0 +1,39 @@
from ayon_core.lib import (
BoolDef
)
from ayon_core.pipeline import (
load,
registered_host
)
from ayon_core.hosts.maya.api.workfile_template_builder import (
MayaTemplateBuilder
)
class LoadAsTemplate(load.LoaderPlugin):
"""Load workfile as a template """
product_types = {"workfile", "mayaScene"}
label = "Load as template"
representations = ["ma", "mb"]
icon = "wrench"
color = "#775555"
order = 10
options = [
BoolDef("keep_placeholders",
label="Keep Placeholders",
default=False),
BoolDef("create_first_version",
label="Create First Version",
default=False),
]
def load(self, context, name, namespace, data):
keep_placeholders = data.get("keep_placeholders", False)
create_first_version = data.get("create_first_version", False)
path = self.filepath_from_context(context)
builder = MayaTemplateBuilder(registered_host())
builder.build_template(template_path=path,
keep_placeholders=keep_placeholders,
create_first_version=create_first_version)

View file

@ -12,6 +12,7 @@ from ayon_core.pipeline import (
get_representation_path
)
from ayon_core.hosts.maya.api import lib
from ayon_core.hosts.maya.api.yeti import create_yeti_variable
from ayon_core.hosts.maya.api.pipeline import containerise
from ayon_core.hosts.maya.api.plugin import get_load_color_for_product_type
@ -23,8 +24,19 @@ SKIP_UPDATE_ATTRS = {
"viewportDensity",
"viewportWidth",
"viewportLength",
"renderDensity",
"renderWidth",
"renderLength",
"increaseRenderBounds"
}
SKIP_ATTR_MESSAGE = (
"Skipping updating %s.%s to %s because it "
"is considered a local overridable attribute. "
"Either set manually or the load the cache "
"anew."
)
def set_attribute(node, attr, value):
"""Wrapper of set attribute which ignores None values"""
@ -209,9 +221,31 @@ class YetiCacheLoader(load.LoaderPlugin):
for attr, value in node_settings["attrs"].items():
if attr in SKIP_UPDATE_ATTRS:
self.log.info(
SKIP_ATTR_MESSAGE, yeti_node, attr, value
)
continue
set_attribute(attr, value, yeti_node)
# Set up user defined attributes
user_variables = node_settings.get("user_variables", {})
for attr, value in user_variables.items():
was_value_set = create_yeti_variable(
yeti_shape_node=yeti_node,
attr_name=attr,
value=value,
# We do not want to update the
# value if it already exists so
# that any local overrides that
# may have been applied still
# persist
force_value=False
)
if not was_value_set:
self.log.info(
SKIP_ATTR_MESSAGE, yeti_node, attr, value
)
cmds.setAttr("{}.representation".format(container_node),
repre_entity["id"],
typ="string")
@ -332,6 +366,13 @@ class YetiCacheLoader(load.LoaderPlugin):
for attr, value in attributes.items():
set_attribute(attr, value, yeti_node)
# Set up user defined attributes
user_variables = node_settings.get("user_variables", {})
for attr, value in user_variables.items():
create_yeti_variable(yeti_shape_node=yeti_node,
attr_name=attr,
value=value)
# Connect to the time node
cmds.connectAttr("time1.outTime", "%s.currentTime" % yeti_node)

View file

@ -1,8 +1,13 @@
from typing import List
import maya.cmds as cmds
from ayon_core.hosts.maya.api import plugin
from ayon_core.hosts.maya.api import lib
from ayon_core.pipeline import registered_host
from ayon_core.pipeline.create import CreateContext
class YetiRigLoader(plugin.ReferenceLoader):
"""This loader will load Yeti rig."""
@ -15,6 +20,9 @@ class YetiRigLoader(plugin.ReferenceLoader):
icon = "code-fork"
color = "orange"
# From settings
create_cache_instance_on_load = True
def process_reference(
self, context, name=None, namespace=None, options=None
):
@ -49,4 +57,41 @@ class YetiRigLoader(plugin.ReferenceLoader):
)
self[:] = nodes
if self.create_cache_instance_on_load:
# Automatically create in instance to allow publishing the loaded
# yeti rig into a yeti cache
self._create_yeti_cache_instance(nodes, variant=namespace)
return nodes
def _create_yeti_cache_instance(self, nodes: List[str], variant: str):
"""Create a yeticache product type instance to publish the output.
This is similar to how loading animation rig will automatically create
an animation instance for publishing any loaded character rigs, but
then for yeti rigs.
Args:
nodes (List[str]): Nodes generated on load.
variant (str): Variant for the yeti cache instance to create.
"""
# Find the roots amongst the loaded nodes
yeti_nodes = cmds.ls(nodes, type="pgYetiMaya", long=True)
assert yeti_nodes, "No pgYetiMaya nodes in rig, this is a bug."
self.log.info("Creating variant: {}".format(variant))
creator_identifier = "io.openpype.creators.maya.yeticache"
host = registered_host()
create_context = CreateContext(host)
with lib.maintained_selection():
cmds.select(yeti_nodes, noExpand=True)
create_context.create(
creator_identifier=creator_identifier,
variant=variant,
pre_create_data={"use_selection": True}
)

View file

@ -58,4 +58,3 @@ class CollectAnimationOutputGeometry(pyblish.api.InstancePlugin):
if instance.data.get("farm"):
instance.data["families"].append("publish.farm")

View file

@ -12,7 +12,7 @@ class CollectFileDependencies(pyblish.api.ContextPlugin):
families = ["renderlayer"]
@classmethod
def apply_settings(cls, project_settings, system_settings):
def apply_settings(cls, project_settings):
# Disable plug-in if not used for deadline submission anyway
settings = project_settings["deadline"]["publish"]["MayaSubmitDeadline"] # noqa
cls.enabled = settings.get("asset_dependencies", True)

View file

@ -14,7 +14,9 @@ class CollectUserDefinedAttributes(pyblish.api.InstancePlugin):
def process(self, instance):
# Collect user defined attributes.
if not instance.data.get("includeUserDefinedAttributes", False):
if not instance.data["creator_attributes"].get(
"includeUserDefinedAttributes"
):
return
if "out_hierarchy" in instance.data:

View file

@ -3,6 +3,7 @@ from maya import cmds
import pyblish.api
from ayon_core.hosts.maya.api import lib
from ayon_core.hosts.maya.api.yeti import get_yeti_user_variables
SETTINGS = {
@ -34,7 +35,7 @@ class CollectYetiCache(pyblish.api.InstancePlugin):
- "increaseRenderBounds"
- "imageSearchPath"
Other information is the name of the transform and it's Colorbleed ID
Other information is the name of the transform and its `cbId`
"""
order = pyblish.api.CollectorOrder + 0.45
@ -54,6 +55,16 @@ class CollectYetiCache(pyblish.api.InstancePlugin):
# Get specific node attributes
attr_data = {}
for attr in SETTINGS:
# Ignore non-existing attributes with a warning, e.g. cbId
# if they have not been generated yet
if not cmds.attributeQuery(attr, node=shape, exists=True):
self.log.warning(
"Attribute '{}' not found on Yeti node: {}".format(
attr, shape
)
)
continue
current = cmds.getAttr("%s.%s" % (shape, attr))
# change None to empty string as Maya doesn't support
# NoneType in attributes
@ -61,6 +72,12 @@ class CollectYetiCache(pyblish.api.InstancePlugin):
current = ""
attr_data[attr] = current
# Get user variable attributes
user_variable_attrs = {
attr: lib.get_attribute("{}.{}".format(shape, attr))
for attr in get_yeti_user_variables(shape)
}
# Get transform data
parent = cmds.listRelatives(shape, parent=True)[0]
transform_data = {"name": parent, "cbId": lib.get_id(parent)}
@ -70,6 +87,7 @@ class CollectYetiCache(pyblish.api.InstancePlugin):
"name": shape,
"cbId": lib.get_id(shape),
"attrs": attr_data,
"user_variables": user_variable_attrs
}
settings["nodes"].append(shape_data)

View file

@ -2,7 +2,7 @@ import os
import json
from ayon_core.pipeline import publish
from ayon_core.hosts.maya.api.lib import extract_alembic
from ayon_core.hosts.maya.api.alembic import extract_alembic
from maya import cmds

View file

@ -1,17 +1,28 @@
import os
from collections import OrderedDict
from maya import cmds
from ayon_core.pipeline import publish
from ayon_core.hosts.maya.api.alembic import extract_alembic
from ayon_core.hosts.maya.api.lib import (
extract_alembic,
suspended_refresh,
maintained_selection,
iter_visible_nodes_in_range
)
from ayon_core.lib import (
BoolDef,
TextDef,
NumberDef,
EnumDef,
UISeparatorDef,
UILabelDef,
)
from ayon_core.pipeline.publish import AYONPyblishPluginMixin
from ayon_core.pipeline import KnownPublishError
class ExtractAlembic(publish.Extractor):
class ExtractAlembic(publish.Extractor, AYONPyblishPluginMixin):
"""Produce an alembic of just point positions and normals.
Positions and normals, uvs, creases are preserved, but nothing more,
@ -27,8 +38,35 @@ class ExtractAlembic(publish.Extractor):
targets = ["local", "remote"]
# From settings
attr = []
attrPrefix = []
autoSubd = False
bake_attributes = []
bake_attribute_prefixes = []
dataFormat = "ogawa"
eulerFilter = False
melPerFrameCallback = ""
melPostJobCallback = ""
overrides = []
preRoll = False
preRollStartFrame = 0
pythonPerFrameCallback = ""
pythonPostJobCallback = ""
renderableOnly = False
stripNamespaces = True
uvsOnly = False
uvWrite = False
userAttr = ""
userAttrPrefix = ""
verbose = False
visibleOnly = False
wholeFrameGeo = False
worldSpace = True
writeColorSets = False
writeFaceSets = False
writeNormals = True
writeUVSets = False
writeVisibility = False
def process(self, instance):
if instance.data.get("farm"):
@ -41,16 +79,38 @@ class ExtractAlembic(publish.Extractor):
start = float(instance.data.get("frameStartHandle", 1))
end = float(instance.data.get("frameEndHandle", 1))
attrs = instance.data.get("attr", "").split(";")
attrs = [value for value in attrs if value.strip()]
attribute_values = self.get_attr_values_from_data(
instance.data
)
attrs = [
attr.strip()
for attr in attribute_values.get("attr", "").split(";")
if attr.strip()
]
attrs += instance.data.get("userDefinedAttributes", [])
attrs += self.bake_attributes
attrs += ["cbId"]
attr_prefixes = instance.data.get("attrPrefix", "").split(";")
attr_prefixes = [value for value in attr_prefixes if value.strip()]
attr_prefixes = [
attr.strip()
for attr in attribute_values.get("attrPrefix", "").split(";")
if attr.strip()
]
attr_prefixes += self.bake_attribute_prefixes
user_attrs = [
attr.strip()
for attr in attribute_values.get("userAttr", "").split(";")
if attr.strip()
]
user_attr_prefixes = [
attr.strip()
for attr in attribute_values.get("userAttrPrefix", "").split(";")
if attr.strip()
]
self.log.debug("Extracting pointcache..")
dirname = self.staging_dir(instance)
@ -58,28 +118,83 @@ class ExtractAlembic(publish.Extractor):
filename = "{name}.abc".format(**instance.data)
path = os.path.join(parent_dir, filename)
options = {
"step": instance.data.get("step", 1.0),
"attr": attrs,
"attrPrefix": attr_prefixes,
"writeVisibility": True,
"writeCreases": True,
"writeColorSets": instance.data.get("writeColorSets", False),
"writeFaceSets": instance.data.get("writeFaceSets", False),
"uvWrite": True,
"selection": True,
"worldSpace": instance.data.get("worldSpace", True)
}
root = None
if not instance.data.get("includeParentHierarchy", True):
# Set the root nodes if we don't want to include parents
# The roots are to be considered the ones that are the actual
# direct members of the set
options["root"] = roots
root = roots
if int(cmds.about(version=True)) >= 2017:
# Since Maya 2017 alembic supports multiple uv sets - write them.
options["writeUVSets"] = True
kwargs = {
"file": path,
"attr": attrs,
"attrPrefix": attr_prefixes,
"userAttr": user_attrs,
"userAttrPrefix": user_attr_prefixes,
"dataFormat": attribute_values.get("dataFormat", self.dataFormat),
"endFrame": end,
"eulerFilter": attribute_values.get(
"eulerFilter", self.eulerFilter
),
"preRoll": attribute_values.get("preRoll", self.preRoll),
"preRollStartFrame": attribute_values.get(
"preRollStartFrame", self.preRollStartFrame
),
"renderableOnly": attribute_values.get(
"renderableOnly", self.renderableOnly
),
"root": root,
"selection": True,
"startFrame": start,
"step": instance.data.get(
"creator_attributes", {}
).get("step", 1.0),
"stripNamespaces": attribute_values.get(
"stripNamespaces", self.stripNamespaces
),
"uvWrite": attribute_values.get("uvWrite", self.uvWrite),
"verbose": attribute_values.get("verbose", self.verbose),
"wholeFrameGeo": attribute_values.get(
"wholeFrameGeo", self.wholeFrameGeo
),
"worldSpace": attribute_values.get("worldSpace", self.worldSpace),
"writeColorSets": attribute_values.get(
"writeColorSets", self.writeColorSets
),
"writeCreases": attribute_values.get(
"writeCreases", self.writeCreases
),
"writeFaceSets": attribute_values.get(
"writeFaceSets", self.writeFaceSets
),
"writeUVSets": attribute_values.get(
"writeUVSets", self.writeUVSets
),
"writeVisibility": attribute_values.get(
"writeVisibility", self.writeVisibility
),
"autoSubd": attribute_values.get(
"autoSubd", self.autoSubd
),
"uvsOnly": attribute_values.get(
"uvsOnly", self.uvsOnly
),
"writeNormals": attribute_values.get(
"writeNormals", self.writeNormals
),
"melPerFrameCallback": attribute_values.get(
"melPerFrameCallback", self.melPerFrameCallback
),
"melPostJobCallback": attribute_values.get(
"melPostJobCallback", self.melPostJobCallback
),
"pythonPerFrameCallback": attribute_values.get(
"pythonPerFrameCallback", self.pythonPostJobCallback
),
"pythonPostJobCallback": attribute_values.get(
"pythonPostJobCallback", self.pythonPostJobCallback
)
}
if instance.data.get("visibleOnly", False):
# If we only want to include nodes that are visible in the frame
@ -87,20 +202,19 @@ class ExtractAlembic(publish.Extractor):
# flag does not filter out those that are only hidden on some
# frames as it counts "animated" or "connected" visibilities as
# if it's always visible.
nodes = list(iter_visible_nodes_in_range(nodes,
start=start,
end=end))
nodes = list(
iter_visible_nodes_in_range(nodes, start=start, end=end)
)
suspend = not instance.data.get("refresh", False)
with suspended_refresh(suspend=suspend):
with maintained_selection():
cmds.select(nodes, noExpand=True)
extract_alembic(
file=path,
startFrame=start,
endFrame=end,
**options
self.log.debug(
"Running `extract_alembic` with the keyword arguments: "
"{}".format(kwargs)
)
extract_alembic(**kwargs)
if "representations" not in instance.data:
instance.data["representations"] = []
@ -124,21 +238,17 @@ class ExtractAlembic(publish.Extractor):
return
path = path.replace(".abc", "_proxy.abc")
kwargs["file"] = path
if not instance.data.get("includeParentHierarchy", True):
# Set the root nodes if we don't want to include parents
# The roots are to be considered the ones that are the actual
# direct members of the set
options["root"] = instance.data["proxyRoots"]
kwargs["root"] = instance.data["proxyRoots"]
with suspended_refresh(suspend=suspend):
with maintained_selection():
cmds.select(instance.data["proxy"])
extract_alembic(
file=path,
startFrame=start,
endFrame=end,
**options
)
extract_alembic(**kwargs)
representation = {
"name": "proxy",
@ -152,24 +262,274 @@ class ExtractAlembic(publish.Extractor):
def get_members_and_roots(self, instance):
return instance[:], instance.data.get("setMembers")
@classmethod
def get_attribute_defs(cls):
if not cls.overrides:
return []
override_defs = OrderedDict({
"autoSubd": BoolDef(
"autoSubd",
label="Auto Subd",
default=cls.autoSubd,
tooltip=(
"If this flag is present and the mesh has crease edges, "
"crease vertices or holes, the mesh (OPolyMesh) would now "
"be written out as an OSubD and crease info will be stored"
" in the Alembic file. Otherwise, creases info won't be "
"preserved in Alembic file unless a custom Boolean "
"attribute SubDivisionMesh has been added to mesh node and"
" its value is true."
)
),
"eulerFilter": BoolDef(
"eulerFilter",
label="Euler Filter",
default=cls.eulerFilter,
tooltip="Apply Euler filter while sampling rotations."
),
"renderableOnly": BoolDef(
"renderableOnly",
label="Renderable Only",
default=cls.renderableOnly,
tooltip="Only export renderable visible shapes."
),
"stripNamespaces": BoolDef(
"stripNamespaces",
label="Strip Namespaces",
default=cls.stripNamespaces,
tooltip=(
"Namespaces will be stripped off of the node before being "
"written to Alembic."
)
),
"uvsOnly": BoolDef(
"uvsOnly",
label="UVs Only",
default=cls.uvsOnly,
tooltip=(
"If this flag is present, only uv data for PolyMesh and "
"SubD shapes will be written to the Alembic file."
)
),
"uvWrite": BoolDef(
"uvWrite",
label="UV Write",
default=cls.uvWrite,
tooltip=(
"Uv data for PolyMesh and SubD shapes will be written to "
"the Alembic file."
)
),
"verbose": BoolDef(
"verbose",
label="Verbose",
default=cls.verbose,
tooltip="Prints the current frame that is being evaluated."
),
"visibleOnly": BoolDef(
"visibleOnly",
label="Visible Only",
default=cls.visibleOnly,
tooltip="Only export dag objects visible during frame range."
),
"wholeFrameGeo": BoolDef(
"wholeFrameGeo",
label="Whole Frame Geo",
default=cls.wholeFrameGeo,
tooltip=(
"Data for geometry will only be written out on whole "
"frames."
)
),
"worldSpace": BoolDef(
"worldSpace",
label="World Space",
default=cls.worldSpace,
tooltip="Any root nodes will be stored in world space."
),
"writeColorSets": BoolDef(
"writeColorSets",
label="Write Color Sets",
default=cls.writeColorSets,
tooltip="Write vertex colors with the geometry."
),
"writeFaceSets": BoolDef(
"writeFaceSets",
label="Write Face Sets",
default=cls.writeFaceSets,
tooltip="Write face sets with the geometry."
),
"writeNormals": BoolDef(
"writeNormals",
label="Write Normals",
default=cls.writeNormals,
tooltip="Write normals with the deforming geometry."
),
"writeUVSets": BoolDef(
"writeUVSets",
label="Write UV Sets",
default=cls.writeUVSets,
tooltip=(
"Write all uv sets on MFnMeshes as vector 2 indexed "
"geometry parameters with face varying scope."
)
),
"writeVisibility": BoolDef(
"writeVisibility",
label="Write Visibility",
default=cls.writeVisibility,
tooltip=(
"Visibility state will be stored in the Alembic file. "
"Otherwise everything written out is treated as visible."
)
),
"preRoll": BoolDef(
"preRoll",
label="Pre Roll",
default=cls.preRoll,
tooltip="This frame range will not be sampled."
),
"preRollStartFrame": NumberDef(
"preRollStartFrame",
label="Pre Roll Start Frame",
tooltip=(
"The frame to start scene evaluation at. This is used"
" to set the starting frame for time dependent "
"translations and can be used to evaluate run-up that"
" isn't actually translated."
),
default=cls.preRollStartFrame
),
"dataFormat": EnumDef(
"dataFormat",
label="Data Format",
items=["ogawa", "HDF"],
default=cls.dataFormat,
tooltip="The data format to use to write the file."
),
"attr": TextDef(
"attr",
label="Custom Attributes",
placeholder="attr1; attr2; ...",
default=cls.attr,
tooltip=(
"Attributes matching by name will be included in the "
"Alembic export. Attributes should be separated by "
"semi-colon `;`"
)
),
"attrPrefix": TextDef(
"attrPrefix",
label="Custom Attributes Prefix",
placeholder="prefix1; prefix2; ...",
default=cls.attrPrefix,
tooltip=(
"Attributes starting with these prefixes will be included "
"in the Alembic export. Attributes should be separated by "
"semi-colon `;`"
)
),
"userAttr": TextDef(
"userAttr",
label="User Attr",
placeholder="attr1; attr2; ...",
default=cls.userAttr,
tooltip=(
"Attributes matching by name will be included in the "
"Alembic export. Attributes should be separated by "
"semi-colon `;`"
)
),
"userAttrPrefix": TextDef(
"userAttrPrefix",
label="User Attr Prefix",
placeholder="prefix1; prefix2; ...",
default=cls.userAttrPrefix,
tooltip=(
"Attributes starting with these prefixes will be included "
"in the Alembic export. Attributes should be separated by "
"semi-colon `;`"
)
),
"melPerFrameCallback": TextDef(
"melPerFrameCallback",
label="Mel Per Frame Callback",
default=cls.melPerFrameCallback,
tooltip=(
"When each frame (and the static frame) is evaluated the "
"string specified is evaluated as a Mel command."
)
),
"melPostJobCallback": TextDef(
"melPostJobCallback",
label="Mel Post Job Callback",
default=cls.melPostJobCallback,
tooltip=(
"When the translation has finished the string specified "
"is evaluated as a Mel command."
)
),
"pythonPerFrameCallback": TextDef(
"pythonPerFrameCallback",
label="Python Per Frame Callback",
default=cls.pythonPerFrameCallback,
tooltip=(
"When each frame (and the static frame) is evaluated the "
"string specified is evaluated as a python command."
)
),
"pythonPostJobCallback": TextDef(
"pythonPostJobCallback",
label="Python Post Frame Callback",
default=cls.pythonPostJobCallback,
tooltip=(
"When the translation has finished the string specified "
"is evaluated as a python command."
)
)
})
defs = super(ExtractAlembic, cls).get_attribute_defs()
defs.extend([
UISeparatorDef("sep_alembic_options"),
UILabelDef("Alembic Options"),
])
# The Arguments that can be modified by the Publisher
overrides = set(cls.overrides)
for key, value in override_defs.items():
if key not in overrides:
continue
defs.append(value)
defs.append(
UISeparatorDef("sep_alembic_options_end")
)
return defs
class ExtractAnimation(ExtractAlembic):
label = "Extract Animation"
label = "Extract Animation (Alembic)"
families = ["animation"]
def get_members_and_roots(self, instance):
# Collect the out set nodes
out_sets = [node for node in instance if node.endswith("out_SET")]
if len(out_sets) != 1:
raise RuntimeError("Couldn't find exactly one out_SET: "
"{0}".format(out_sets))
raise KnownPublishError(
"Couldn't find exactly one out_SET: {0}".format(out_sets)
)
out_set = out_sets[0]
roots = cmds.sets(out_set, query=True)
roots = cmds.sets(out_set, query=True) or []
# Include all descendants
nodes = roots + cmds.listRelatives(roots,
allDescendents=True,
fullPath=True) or []
nodes = roots
nodes += cmds.listRelatives(
roots, allDescendents=True, fullPath=True
) or []
return nodes, roots

View file

@ -3,8 +3,8 @@ import os
from maya import cmds
from ayon_core.pipeline import publish
from ayon_core.hosts.maya.api.alembic import extract_alembic
from ayon_core.hosts.maya.api.lib import (
extract_alembic,
suspended_refresh,
maintained_selection,
iter_visible_nodes_in_range

View file

@ -5,7 +5,13 @@ import os
from maya import cmds
from ayon_core.pipeline import publish
from ayon_core.hosts.maya.api.lib import maintained_selection
from ayon_core.hosts.maya.api.lib import (
maintained_selection,
renderlayer
)
from ayon_core.hosts.maya.api.render_setup_tools import (
allow_export_from_render_setup_layer
)
class ExtractRedshiftProxy(publish.Extractor):
@ -18,6 +24,9 @@ class ExtractRedshiftProxy(publish.Extractor):
def process(self, instance):
"""Extractor entry point."""
# Make sure Redshift is loaded
cmds.loadPlugin("redshift4maya", quiet=True)
staging_dir = self.staging_dir(instance)
file_name = "{}.rs".format(instance.name)
file_path = os.path.join(staging_dir, file_name)
@ -60,14 +69,22 @@ class ExtractRedshiftProxy(publish.Extractor):
# Write out rs file
self.log.debug("Writing: '%s'" % file_path)
# Allow overriding what renderlayer to export from. By default force
# it to the default render layer. (Note that the renderlayer isn't
# currently exposed as an attribute to artists)
layer = instance.data.get("renderLayer", "defaultRenderLayer")
with maintained_selection():
cmds.select(instance.data["setMembers"], noExpand=True)
cmds.file(file_path,
pr=False,
force=True,
type="Redshift Proxy",
exportSelected=True,
options=rs_options)
with renderlayer(layer):
with allow_export_from_render_setup_layer():
cmds.select(instance.data["setMembers"], noExpand=True)
cmds.file(file_path,
preserveReferences=False,
force=True,
type="Redshift Proxy",
exportSelected=True,
options=rs_options)
if "representations" not in instance.data:
instance.data["representations"] = []

View file

@ -5,8 +5,8 @@ import os
from maya import cmds # noqa
from ayon_core.pipeline import publish
from ayon_core.hosts.maya.api.alembic import extract_alembic
from ayon_core.hosts.maya.api.lib import (
extract_alembic,
suspended_refresh,
maintained_selection
)

View file

@ -5,7 +5,7 @@ import copy
from maya import cmds
import pyblish.api
from ayon_core.hosts.maya.api.lib import extract_alembic
from ayon_core.hosts.maya.api.alembic import extract_alembic
from ayon_core.pipeline import publish

View file

@ -0,0 +1,98 @@
import pyblish.api
from ayon_core.pipeline import OptionalPyblishPluginMixin
from ayon_core.pipeline.publish import RepairAction, PublishValidationError
class ValidateAlembicDefaultsPointcache(
pyblish.api.InstancePlugin, OptionalPyblishPluginMixin
):
"""Validate the attributes on the instance are defaults.
The defaults are defined in the project settings.
"""
order = pyblish.api.ValidatorOrder
families = ["pointcache"]
hosts = ["maya"]
label = "Validate Alembic Options Defaults"
actions = [RepairAction]
optional = True
plugin_name = "ExtractAlembic"
@classmethod
def _get_settings(cls, context):
maya_settings = context.data["project_settings"]["maya"]
settings = maya_settings["publish"]["ExtractAlembic"]
return settings
@classmethod
def _get_publish_attributes(cls, instance):
attributes = instance.data["publish_attributes"][
cls.plugin_name(
instance.data["publish_attributes"]
)
]
return attributes
def process(self, instance):
if not self.is_active(instance.data):
return
settings = self._get_settings(instance.context)
attributes = self._get_publish_attributes(instance)
msg = (
"Alembic Extract setting \"{}\" is not the default value:"
"\nCurrent: {}"
"\nDefault Value: {}\n"
)
errors = []
for key, value in attributes.items():
default_value = settings[key]
# Lists are best to compared sorted since we cant rely on the order
# of the items.
if isinstance(value, list):
value = sorted(value)
default_value = sorted(default_value)
if value != default_value:
errors.append(msg.format(key, value, default_value))
if errors:
raise PublishValidationError("\n".join(errors))
@classmethod
def repair(cls, instance):
# Find create instance twin.
create_context = instance.context.data["create_context"]
create_instance = create_context.get_instance_by_id(
instance.data["instance_id"]
)
# Set the settings values on the create context then save to workfile.
publish_attributes = instance.data["publish_attributes"]
plugin_name = cls.plugin_name(publish_attributes)
attributes = cls._get_publish_attributes(instance)
settings = cls._get_settings(instance.context)
create_publish_attributes = create_instance.data["publish_attributes"]
for key in attributes:
create_publish_attributes[plugin_name][key] = settings[key]
create_context.save_changes()
class ValidateAlembicDefaultsAnimation(
ValidateAlembicDefaultsPointcache
):
"""Validate the attributes on the instance are defaults.
The defaults are defined in the project settings.
"""
label = "Validate Alembic Options Defaults"
families = ["animation"]
plugin_name = "ExtractAnimation"

View file

@ -0,0 +1,270 @@
import json
from maya import cmds
from ayon_core.pipeline.workfile.workfile_template_builder import (
PlaceholderPlugin,
LoadPlaceholderItem,
PlaceholderLoadMixin,
)
from ayon_core.hosts.maya.api.lib import (
read,
imprint,
get_reference_node
)
from ayon_core.hosts.maya.api.workfile_template_builder import PLACEHOLDER_SET
class MayaPlaceholderLoadPlugin(PlaceholderPlugin, PlaceholderLoadMixin):
identifier = "maya.load"
label = "Maya load"
def _collect_scene_placeholders(self):
# Cache placeholder data to shared data
placeholder_nodes = self.builder.get_shared_populate_data(
"placeholder_nodes"
)
if placeholder_nodes is None:
attributes = cmds.ls("*.plugin_identifier", long=True)
placeholder_nodes = {}
for attribute in attributes:
node_name = attribute.rpartition(".")[0]
placeholder_nodes[node_name] = (
self._parse_placeholder_node_data(node_name)
)
self.builder.set_shared_populate_data(
"placeholder_nodes", placeholder_nodes
)
return placeholder_nodes
def _parse_placeholder_node_data(self, node_name):
placeholder_data = read(node_name)
parent_name = (
cmds.getAttr(node_name + ".parent", asString=True)
or node_name.rpartition("|")[0]
or ""
)
if parent_name:
siblings = cmds.listRelatives(parent_name, children=True)
else:
siblings = cmds.ls(assemblies=True)
node_shortname = node_name.rpartition("|")[2]
current_index = cmds.getAttr(node_name + ".index", asString=True)
if current_index < 0:
current_index = siblings.index(node_shortname)
placeholder_data.update({
"parent": parent_name,
"index": current_index
})
return placeholder_data
def _create_placeholder_name(self, placeholder_data):
placeholder_name_parts = placeholder_data["builder_type"].split("_")
pos = 1
placeholder_product_type = placeholder_data.get("product_type")
if placeholder_product_type is None:
placeholder_product_type = placeholder_data.get("family")
if placeholder_product_type:
placeholder_name_parts.insert(pos, placeholder_product_type)
pos += 1
# add loader arguments if any
loader_args = placeholder_data["loader_args"]
if loader_args:
loader_args = json.loads(loader_args.replace('\'', '\"'))
values = [v for v in loader_args.values()]
for value in values:
placeholder_name_parts.insert(pos, value)
pos += 1
placeholder_name = "_".join(placeholder_name_parts)
return placeholder_name.capitalize()
def _get_loaded_repre_ids(self):
loaded_representation_ids = self.builder.get_shared_populate_data(
"loaded_representation_ids"
)
if loaded_representation_ids is None:
try:
containers = cmds.sets("AVALON_CONTAINERS", q=True)
except ValueError:
containers = []
loaded_representation_ids = {
cmds.getAttr(container + ".representation")
for container in containers
}
self.builder.set_shared_populate_data(
"loaded_representation_ids", loaded_representation_ids
)
return loaded_representation_ids
def create_placeholder(self, placeholder_data):
selection = cmds.ls(selection=True)
if len(selection) > 1:
raise ValueError("More then one item are selected")
parent = selection[0] if selection else None
placeholder_data["plugin_identifier"] = self.identifier
placeholder_name = self._create_placeholder_name(placeholder_data)
placeholder = cmds.spaceLocator(name=placeholder_name)[0]
if parent:
placeholder = cmds.parent(placeholder, selection[0])[0]
imprint(placeholder, placeholder_data)
# Add helper attributes to keep placeholder info
cmds.addAttr(
placeholder,
longName="parent",
hidden=True,
dataType="string"
)
cmds.addAttr(
placeholder,
longName="index",
hidden=True,
attributeType="short",
defaultValue=-1
)
cmds.setAttr(placeholder + ".parent", "", type="string")
def update_placeholder(self, placeholder_item, placeholder_data):
node_name = placeholder_item.scene_identifier
new_values = {}
for key, value in placeholder_data.items():
placeholder_value = placeholder_item.data.get(key)
if value != placeholder_value:
new_values[key] = value
placeholder_item.data[key] = value
for key in new_values.keys():
cmds.deleteAttr(node_name + "." + key)
imprint(node_name, new_values)
def collect_placeholders(self):
output = []
scene_placeholders = self._collect_scene_placeholders()
for node_name, placeholder_data in scene_placeholders.items():
if placeholder_data.get("plugin_identifier") != self.identifier:
continue
# TODO do data validations and maybe upgrades if they are invalid
output.append(
LoadPlaceholderItem(node_name, placeholder_data, self)
)
return output
def populate_placeholder(self, placeholder):
self.populate_load_placeholder(placeholder)
def repopulate_placeholder(self, placeholder):
repre_ids = self._get_loaded_repre_ids()
self.populate_load_placeholder(placeholder, repre_ids)
def get_placeholder_options(self, options=None):
return self.get_load_plugin_options(options)
def post_placeholder_process(self, placeholder, failed):
"""Cleanup placeholder after load of its corresponding representations.
Args:
placeholder (PlaceholderItem): Item which was just used to load
representation.
failed (bool): Loading of representation failed.
"""
# Hide placeholder and add them to placeholder set
node = placeholder.scene_identifier
# If we just populate the placeholders from current scene, the
# placeholder set will not be created so account for that.
if not cmds.objExists(PLACEHOLDER_SET):
cmds.sets(name=PLACEHOLDER_SET, empty=True)
cmds.sets(node, addElement=PLACEHOLDER_SET)
cmds.hide(node)
cmds.setAttr(node + ".hiddenInOutliner", True)
def delete_placeholder(self, placeholder):
"""Remove placeholder if building was successful"""
cmds.delete(placeholder.scene_identifier)
def load_succeed(self, placeholder, container):
self._parent_in_hierarchy(placeholder, container)
def _parent_in_hierarchy(self, placeholder, container):
"""Parent loaded container to placeholder's parent.
ie : Set loaded content as placeholder's sibling
Args:
container (str): Placeholder loaded containers
"""
if not container:
return
roots = cmds.sets(container, q=True) or []
ref_node = None
try:
ref_node = get_reference_node(roots)
except AssertionError as e:
self.log.info(e.args[0])
nodes_to_parent = []
for root in roots:
if ref_node:
ref_root = cmds.referenceQuery(root, nodes=True)[0]
ref_root = (
cmds.listRelatives(ref_root, parent=True, path=True) or
[ref_root]
)
nodes_to_parent.extend(ref_root)
continue
if root.endswith("_RN"):
# Backwards compatibility for hardcoded reference names.
refRoot = cmds.referenceQuery(root, n=True)[0]
refRoot = cmds.listRelatives(refRoot, parent=True) or [refRoot]
nodes_to_parent.extend(refRoot)
elif root not in cmds.listSets(allSets=True):
nodes_to_parent.append(root)
elif not cmds.sets(root, q=True):
return
# Move loaded nodes to correct index in outliner hierarchy
placeholder_form = cmds.xform(
placeholder.scene_identifier,
q=True,
matrix=True,
worldSpace=True
)
scene_parent = cmds.listRelatives(
placeholder.scene_identifier, parent=True, fullPath=True
)
for node in set(nodes_to_parent):
cmds.reorder(node, front=True)
cmds.reorder(node, relative=placeholder.data["index"])
cmds.xform(node, matrix=placeholder_form, ws=True)
if scene_parent:
cmds.parent(node, scene_parent)
else:
if cmds.listRelatives(node, parent=True):
cmds.parent(node, world=True)
holding_sets = cmds.listSets(object=placeholder.scene_identifier)
if not holding_sets:
return
for holding_set in holding_sets:
cmds.sets(roots, forceElement=holding_set)

View file

@ -7,7 +7,7 @@ from maya import cmds
import ayon_api
from ayon_core.pipeline import get_current_project_name
import ayon_core.hosts.maya.lib as maya_lib
import ayon_core.hosts.maya.api.lib as maya_lib
from . import lib
from .alembic import get_alembic_ids_cache

View file

@ -1495,18 +1495,28 @@ class WorkfileSettings(object):
filter_knobs = [
"viewerProcess",
"wipe_position"
"wipe_position",
"monitorOutOutputTransform"
]
display, viewer = get_viewer_config_from_string(
viewer_dict["viewerProcess"]
)
viewer_process = create_viewer_profile_string(
viewer, display, path_like=False
)
display, viewer = get_viewer_config_from_string(
viewer_dict["output_transform"]
)
output_transform = create_viewer_profile_string(
viewer, display, path_like=False
)
erased_viewers = []
for v in nuke.allNodes(filter="Viewer"):
# set viewProcess to preset from settings
v["viewerProcess"].setValue(
str(viewer_dict["viewerProcess"])
)
v["viewerProcess"].setValue(viewer_process)
if str(viewer_dict["viewerProcess"]) \
not in v["viewerProcess"].value():
if viewer_process not in v["viewerProcess"].value():
copy_inputs = v.dependencies()
copy_knobs = {k: v[k].value() for k in v.knobs()
if k not in filter_knobs}
@ -1524,11 +1534,11 @@ class WorkfileSettings(object):
# set copied knobs
for k, v in copy_knobs.items():
print(k, v)
nv[k].setValue(v)
# set viewerProcess
nv["viewerProcess"].setValue(str(viewer_dict["viewerProcess"]))
nv["viewerProcess"].setValue(viewer_process)
nv["monitorOutOutputTransform"].setValue(output_transform)
if erased_viewers:
log.warning(
@ -1547,7 +1557,6 @@ class WorkfileSettings(object):
host_name="nuke"
)
viewer_process_settings = imageio_host["viewer"]["viewerProcess"]
workfile_settings = imageio_host["workfile"]
color_management = workfile_settings["color_management"]
native_ocio_config = workfile_settings["native_ocio_config"]
@ -1574,29 +1583,6 @@ class WorkfileSettings(object):
residual_path
))
# get monitor lut from settings respecting Nuke version differences
monitor_lut = workfile_settings["thumbnail_space"]
monitor_lut_data = self._get_monitor_settings(
viewer_process_settings, monitor_lut
)
monitor_lut_data["workingSpaceLUT"] = (
workfile_settings["working_space"]
)
# then set the rest
for knob, value_ in monitor_lut_data.items():
# skip unfilled ocio config path
# it will be dict in value
if isinstance(value_, dict):
continue
# skip empty values
if not value_:
continue
if self._root_node[knob].value() not in value_:
self._root_node[knob].setValue(str(value_))
log.debug("nuke.root()['{}'] changed to: {}".format(
knob, value_))
# set ocio config path
if config_data:
config_path = config_data["path"].replace("\\", "/")
@ -1611,6 +1597,31 @@ class WorkfileSettings(object):
if correct_settings:
self._set_ocio_config_path_to_workfile(config_data)
# get monitor lut from settings respecting Nuke version differences
monitor_lut_data = self._get_monitor_settings(
workfile_settings["monitor_out_lut"],
workfile_settings["monitor_lut"]
)
monitor_lut_data.update({
"workingSpaceLUT": workfile_settings["working_space"],
"int8Lut": workfile_settings["int_8_lut"],
"int16Lut": workfile_settings["int_16_lut"],
"logLut": workfile_settings["log_lut"],
"floatLut": workfile_settings["float_lut"]
})
# then set the rest
for knob, value_ in monitor_lut_data.items():
# skip unfilled ocio config path
# it will be dict in value
if isinstance(value_, dict):
continue
# skip empty values
if not value_:
continue
self._root_node[knob].setValue(str(value_))
log.debug("nuke.root()['{}'] changed to: {}".format(knob, value_))
def _get_monitor_settings(self, viewer_lut, monitor_lut):
""" Get monitor settings from viewer and monitor lut

View file

@ -18,6 +18,7 @@ from ayon_core.pipeline import (
register_loader_plugin_path,
register_creator_plugin_path,
register_inventory_action_path,
register_workfile_build_plugin_path,
AYON_INSTANCE_ID,
AVALON_INSTANCE_ID,
AVALON_CONTAINER_ID,
@ -52,8 +53,6 @@ from .lib import (
MENU_LABEL,
)
from .workfile_template_builder import (
NukePlaceholderLoadPlugin,
NukePlaceholderCreatePlugin,
build_workfile_template,
create_placeholder,
update_placeholder,
@ -76,6 +75,7 @@ PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
WORKFILE_BUILD_PATH = os.path.join(PLUGINS_DIR, "workfile_build")
# registering pyblish gui regarding settings in presets
if os.getenv("PYBLISH_GUI", None):
@ -105,18 +105,11 @@ class NukeHost(
def get_workfile_extensions(self):
return file_extensions()
def get_workfile_build_placeholder_plugins(self):
return [
NukePlaceholderLoadPlugin,
NukePlaceholderCreatePlugin
]
def get_containers(self):
return ls()
def install(self):
''' Installing all requarements for Nuke host
'''
"""Installing all requirements for Nuke host"""
pyblish.api.register_host("nuke")
@ -125,6 +118,7 @@ class NukeHost(
register_loader_plugin_path(LOAD_PATH)
register_creator_plugin_path(CREATE_PATH)
register_inventory_action_path(INVENTORY_PATH)
register_workfile_build_plugin_path(WORKFILE_BUILD_PATH)
# Register AYON event for workfiles loading.
register_event_callback("workio.open_file", check_inventory_versions)
@ -178,7 +172,6 @@ def add_nuke_callbacks():
# set apply all workfile settings on script load and save
nuke.addOnScriptLoad(WorkfileSettings().set_context_settings)
if nuke_settings["dirmap"]["enabled"]:
log.info("Added Nuke's dir-mapping callback ...")
# Add dirmap for file paths.

View file

@ -1151,7 +1151,6 @@ def _remove_old_knobs(node):
"OpenpypeDataGroup", "OpenpypeDataGroup_End", "deadlinePriority",
"deadlineChunkSize", "deadlineConcurrentTasks", "Deadline"
]
print(node.name())
# remove all old knobs
for knob in node.allKnobs():

View file

@ -1,30 +1,17 @@
import collections
import nuke
from ayon_core.pipeline import registered_host
from ayon_core.pipeline.workfile.workfile_template_builder import (
AbstractTemplateBuilder,
PlaceholderPlugin,
LoadPlaceholderItem,
CreatePlaceholderItem,
PlaceholderLoadMixin,
PlaceholderCreateMixin,
)
from ayon_core.tools.workfile_template_build import (
WorkfileBuildPlaceholderDialog,
)
from .lib import (
find_free_space_to_paste_nodes,
get_extreme_positions,
get_group_io_nodes,
imprint,
refresh_node,
refresh_nodes,
reset_selection,
get_names_from_nodes,
get_nodes_by_names,
select_nodes,
duplicate_node,
node_tempfile,
get_main_window,
WorkfileSettings,
)
@ -54,6 +41,7 @@ class NukeTemplateBuilder(AbstractTemplateBuilder):
return True
class NukePlaceholderPlugin(PlaceholderPlugin):
node_color = 4278190335
@ -120,843 +108,6 @@ class NukePlaceholderPlugin(PlaceholderPlugin):
nuke.delete(placeholder_node)
class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin):
identifier = "nuke.load"
label = "Nuke load"
def _parse_placeholder_node_data(self, node):
placeholder_data = super(
NukePlaceholderLoadPlugin, self
)._parse_placeholder_node_data(node)
node_knobs = node.knobs()
nb_children = 0
if "nb_children" in node_knobs:
nb_children = int(node_knobs["nb_children"].getValue())
placeholder_data["nb_children"] = nb_children
siblings = []
if "siblings" in node_knobs:
siblings = node_knobs["siblings"].values()
placeholder_data["siblings"] = siblings
node_full_name = node.fullName()
placeholder_data["group_name"] = node_full_name.rpartition(".")[0]
placeholder_data["last_loaded"] = []
placeholder_data["delete"] = False
return placeholder_data
def _get_loaded_repre_ids(self):
loaded_representation_ids = self.builder.get_shared_populate_data(
"loaded_representation_ids"
)
if loaded_representation_ids is None:
loaded_representation_ids = set()
for node in nuke.allNodes():
if "repre_id" in node.knobs():
loaded_representation_ids.add(
node.knob("repre_id").getValue()
)
self.builder.set_shared_populate_data(
"loaded_representation_ids", loaded_representation_ids
)
return loaded_representation_ids
def _before_placeholder_load(self, placeholder):
placeholder.data["nodes_init"] = nuke.allNodes()
def _before_repre_load(self, placeholder, representation):
placeholder.data["last_repre_id"] = representation["id"]
def collect_placeholders(self):
output = []
scene_placeholders = self._collect_scene_placeholders()
for node_name, node in scene_placeholders.items():
plugin_identifier_knob = node.knob("plugin_identifier")
if (
plugin_identifier_knob is None
or plugin_identifier_knob.getValue() != self.identifier
):
continue
placeholder_data = self._parse_placeholder_node_data(node)
# TODO do data validations and maybe updgrades if are invalid
output.append(
LoadPlaceholderItem(node_name, placeholder_data, self)
)
return output
def populate_placeholder(self, placeholder):
self.populate_load_placeholder(placeholder)
def repopulate_placeholder(self, placeholder):
repre_ids = self._get_loaded_repre_ids()
self.populate_load_placeholder(placeholder, repre_ids)
def get_placeholder_options(self, options=None):
return self.get_load_plugin_options(options)
def post_placeholder_process(self, placeholder, failed):
"""Cleanup placeholder after load of its corresponding representations.
Args:
placeholder (PlaceholderItem): Item which was just used to load
representation.
failed (bool): Loading of representation failed.
"""
# deselect all selected nodes
placeholder_node = nuke.toNode(placeholder.scene_identifier)
# getting the latest nodes added
# TODO get from shared populate data!
nodes_init = placeholder.data["nodes_init"]
nodes_loaded = list(set(nuke.allNodes()) - set(nodes_init))
self.log.debug("Loaded nodes: {}".format(nodes_loaded))
if not nodes_loaded:
return
placeholder.data["delete"] = True
nodes_loaded = self._move_to_placeholder_group(
placeholder, nodes_loaded
)
placeholder.data["last_loaded"] = nodes_loaded
refresh_nodes(nodes_loaded)
# positioning of the loaded nodes
min_x, min_y, _, _ = get_extreme_positions(nodes_loaded)
for node in nodes_loaded:
xpos = (node.xpos() - min_x) + placeholder_node.xpos()
ypos = (node.ypos() - min_y) + placeholder_node.ypos()
node.setXYpos(xpos, ypos)
refresh_nodes(nodes_loaded)
# fix the problem of z_order for backdrops
self._fix_z_order(placeholder)
if placeholder.data.get("keep_placeholder"):
self._imprint_siblings(placeholder)
if placeholder.data["nb_children"] == 0:
# save initial nodes positions and dimensions, update them
# and set inputs and outputs of loaded nodes
if placeholder.data.get("keep_placeholder"):
self._imprint_inits()
self._update_nodes(placeholder, nuke.allNodes(), nodes_loaded)
self._set_loaded_connections(placeholder)
elif placeholder.data["siblings"]:
# create copies of placeholder siblings for the new loaded nodes,
# set their inputs and outputs and update all nodes positions and
# dimensions and siblings names
siblings = get_nodes_by_names(placeholder.data["siblings"])
refresh_nodes(siblings)
copies = self._create_sib_copies(placeholder)
new_nodes = list(copies.values()) # copies nodes
self._update_nodes(new_nodes, nodes_loaded)
placeholder_node.removeKnob(placeholder_node.knob("siblings"))
new_nodes_name = get_names_from_nodes(new_nodes)
imprint(placeholder_node, {"siblings": new_nodes_name})
self._set_copies_connections(placeholder, copies)
self._update_nodes(
nuke.allNodes(),
new_nodes + nodes_loaded,
20
)
new_siblings = get_names_from_nodes(new_nodes)
placeholder.data["siblings"] = new_siblings
else:
# if the placeholder doesn't have siblings, the loaded
# nodes will be placed in a free space
xpointer, ypointer = find_free_space_to_paste_nodes(
nodes_loaded, direction="bottom", offset=200
)
node = nuke.createNode("NoOp")
reset_selection()
nuke.delete(node)
for node in nodes_loaded:
xpos = (node.xpos() - min_x) + xpointer
ypos = (node.ypos() - min_y) + ypointer
node.setXYpos(xpos, ypos)
placeholder.data["nb_children"] += 1
reset_selection()
# go back to root group
nuke.root().begin()
def _move_to_placeholder_group(self, placeholder, nodes_loaded):
"""
opening the placeholder's group and copying loaded nodes in it.
Returns :
nodes_loaded (list): the new list of pasted nodes
"""
groups_name = placeholder.data["group_name"]
reset_selection()
select_nodes(nodes_loaded)
if groups_name:
with node_tempfile() as filepath:
nuke.nodeCopy(filepath)
for node in nuke.selectedNodes():
nuke.delete(node)
group = nuke.toNode(groups_name)
group.begin()
nuke.nodePaste(filepath)
nodes_loaded = nuke.selectedNodes()
return nodes_loaded
def _fix_z_order(self, placeholder):
"""Fix the problem of z_order when a backdrop is loaded."""
nodes_loaded = placeholder.data["last_loaded"]
loaded_backdrops = []
bd_orders = set()
for node in nodes_loaded:
if isinstance(node, nuke.BackdropNode):
loaded_backdrops.append(node)
bd_orders.add(node.knob("z_order").getValue())
if not bd_orders:
return
sib_orders = set()
for node_name in placeholder.data["siblings"]:
node = nuke.toNode(node_name)
if isinstance(node, nuke.BackdropNode):
sib_orders.add(node.knob("z_order").getValue())
if not sib_orders:
return
min_order = min(bd_orders)
max_order = max(sib_orders)
for backdrop_node in loaded_backdrops:
z_order = backdrop_node.knob("z_order").getValue()
backdrop_node.knob("z_order").setValue(
z_order + max_order - min_order + 1)
def _imprint_siblings(self, placeholder):
"""
- add siblings names to placeholder attributes (nodes loaded with it)
- add Id to the attributes of all the other nodes
"""
loaded_nodes = placeholder.data["last_loaded"]
loaded_nodes_set = set(loaded_nodes)
data = {"repre_id": str(placeholder.data["last_repre_id"])}
for node in loaded_nodes:
node_knobs = node.knobs()
if "builder_type" not in node_knobs:
# save the id of representation for all imported nodes
imprint(node, data)
node.knob("repre_id").setVisible(False)
refresh_node(node)
continue
if (
"is_placeholder" not in node_knobs
or (
"is_placeholder" in node_knobs
and node.knob("is_placeholder").value()
)
):
siblings = list(loaded_nodes_set - {node})
siblings_name = get_names_from_nodes(siblings)
siblings = {"siblings": siblings_name}
imprint(node, siblings)
def _imprint_inits(self):
"""Add initial positions and dimensions to the attributes"""
for node in nuke.allNodes():
refresh_node(node)
imprint(node, {"x_init": node.xpos(), "y_init": node.ypos()})
node.knob("x_init").setVisible(False)
node.knob("y_init").setVisible(False)
width = node.screenWidth()
height = node.screenHeight()
if "bdwidth" in node.knobs():
imprint(node, {"w_init": width, "h_init": height})
node.knob("w_init").setVisible(False)
node.knob("h_init").setVisible(False)
refresh_node(node)
def _update_nodes(
self, placeholder, nodes, considered_nodes, offset_y=None
):
"""Adjust backdrop nodes dimensions and positions.
Considering some nodes sizes.
Args:
nodes (list): list of nodes to update
considered_nodes (list): list of nodes to consider while updating
positions and dimensions
offset (int): distance between copies
"""
placeholder_node = nuke.toNode(placeholder.scene_identifier)
min_x, min_y, max_x, max_y = get_extreme_positions(considered_nodes)
diff_x = diff_y = 0
contained_nodes = [] # for backdrops
if offset_y is None:
width_ph = placeholder_node.screenWidth()
height_ph = placeholder_node.screenHeight()
diff_y = max_y - min_y - height_ph
diff_x = max_x - min_x - width_ph
contained_nodes = [placeholder_node]
min_x = placeholder_node.xpos()
min_y = placeholder_node.ypos()
else:
siblings = get_nodes_by_names(placeholder.data["siblings"])
minX, _, maxX, _ = get_extreme_positions(siblings)
diff_y = max_y - min_y + 20
diff_x = abs(max_x - min_x - maxX + minX)
contained_nodes = considered_nodes
if diff_y <= 0 and diff_x <= 0:
return
for node in nodes:
refresh_node(node)
if (
node == placeholder_node
or node in considered_nodes
):
continue
if (
not isinstance(node, nuke.BackdropNode)
or (
isinstance(node, nuke.BackdropNode)
and not set(contained_nodes) <= set(node.getNodes())
)
):
if offset_y is None and node.xpos() >= min_x:
node.setXpos(node.xpos() + diff_x)
if node.ypos() >= min_y:
node.setYpos(node.ypos() + diff_y)
else:
width = node.screenWidth()
height = node.screenHeight()
node.knob("bdwidth").setValue(width + diff_x)
node.knob("bdheight").setValue(height + diff_y)
refresh_node(node)
def _set_loaded_connections(self, placeholder):
"""
set inputs and outputs of loaded nodes"""
placeholder_node = nuke.toNode(placeholder.scene_identifier)
input_node, output_node = get_group_io_nodes(
placeholder.data["last_loaded"]
)
for node in placeholder_node.dependent():
for idx in range(node.inputs()):
if node.input(idx) == placeholder_node and output_node:
node.setInput(idx, output_node)
for node in placeholder_node.dependencies():
for idx in range(placeholder_node.inputs()):
if placeholder_node.input(idx) == node and input_node:
input_node.setInput(0, node)
def _create_sib_copies(self, placeholder):
""" creating copies of the palce_holder siblings (the ones who were
loaded with it) for the new nodes added
Returns :
copies (dict) : with copied nodes names and their copies
"""
copies = {}
siblings = get_nodes_by_names(placeholder.data["siblings"])
for node in siblings:
new_node = duplicate_node(node)
x_init = int(new_node.knob("x_init").getValue())
y_init = int(new_node.knob("y_init").getValue())
new_node.setXYpos(x_init, y_init)
if isinstance(new_node, nuke.BackdropNode):
w_init = new_node.knob("w_init").getValue()
h_init = new_node.knob("h_init").getValue()
new_node.knob("bdwidth").setValue(w_init)
new_node.knob("bdheight").setValue(h_init)
refresh_node(node)
if "repre_id" in node.knobs().keys():
node.removeKnob(node.knob("repre_id"))
copies[node.name()] = new_node
return copies
def _set_copies_connections(self, placeholder, copies):
"""Set inputs and outputs of the copies.
Args:
copies (dict): Copied nodes by their names.
"""
last_input, last_output = get_group_io_nodes(
placeholder.data["last_loaded"]
)
siblings = get_nodes_by_names(placeholder.data["siblings"])
siblings_input, siblings_output = get_group_io_nodes(siblings)
copy_input = copies[siblings_input.name()]
copy_output = copies[siblings_output.name()]
for node_init in siblings:
if node_init == siblings_output:
continue
node_copy = copies[node_init.name()]
for node in node_init.dependent():
for idx in range(node.inputs()):
if node.input(idx) != node_init:
continue
if node in siblings:
copies[node.name()].setInput(idx, node_copy)
else:
last_input.setInput(0, node_copy)
for node in node_init.dependencies():
for idx in range(node_init.inputs()):
if node_init.input(idx) != node:
continue
if node_init == siblings_input:
copy_input.setInput(idx, node)
elif node in siblings:
node_copy.setInput(idx, copies[node.name()])
else:
node_copy.setInput(idx, last_output)
siblings_input.setInput(0, copy_output)
class NukePlaceholderCreatePlugin(
NukePlaceholderPlugin, PlaceholderCreateMixin
):
identifier = "nuke.create"
label = "Nuke create"
def _parse_placeholder_node_data(self, node):
placeholder_data = super(
NukePlaceholderCreatePlugin, self
)._parse_placeholder_node_data(node)
node_knobs = node.knobs()
nb_children = 0
if "nb_children" in node_knobs:
nb_children = int(node_knobs["nb_children"].getValue())
placeholder_data["nb_children"] = nb_children
siblings = []
if "siblings" in node_knobs:
siblings = node_knobs["siblings"].values()
placeholder_data["siblings"] = siblings
node_full_name = node.fullName()
placeholder_data["group_name"] = node_full_name.rpartition(".")[0]
placeholder_data["last_loaded"] = []
placeholder_data["delete"] = False
return placeholder_data
def _before_instance_create(self, placeholder):
placeholder.data["nodes_init"] = nuke.allNodes()
def collect_placeholders(self):
output = []
scene_placeholders = self._collect_scene_placeholders()
for node_name, node in scene_placeholders.items():
plugin_identifier_knob = node.knob("plugin_identifier")
if (
plugin_identifier_knob is None
or plugin_identifier_knob.getValue() != self.identifier
):
continue
placeholder_data = self._parse_placeholder_node_data(node)
output.append(
CreatePlaceholderItem(node_name, placeholder_data, self)
)
return output
def populate_placeholder(self, placeholder):
self.populate_create_placeholder(placeholder)
def repopulate_placeholder(self, placeholder):
self.populate_create_placeholder(placeholder)
def get_placeholder_options(self, options=None):
return self.get_create_plugin_options(options)
def post_placeholder_process(self, placeholder, failed):
"""Cleanup placeholder after load of its corresponding representations.
Args:
placeholder (PlaceholderItem): Item which was just used to load
representation.
failed (bool): Loading of representation failed.
"""
# deselect all selected nodes
placeholder_node = nuke.toNode(placeholder.scene_identifier)
# getting the latest nodes added
nodes_init = placeholder.data["nodes_init"]
nodes_created = list(set(nuke.allNodes()) - set(nodes_init))
self.log.debug("Created nodes: {}".format(nodes_created))
if not nodes_created:
return
placeholder.data["delete"] = True
nodes_created = self._move_to_placeholder_group(
placeholder, nodes_created
)
placeholder.data["last_created"] = nodes_created
refresh_nodes(nodes_created)
# positioning of the created nodes
min_x, min_y, _, _ = get_extreme_positions(nodes_created)
for node in nodes_created:
xpos = (node.xpos() - min_x) + placeholder_node.xpos()
ypos = (node.ypos() - min_y) + placeholder_node.ypos()
node.setXYpos(xpos, ypos)
refresh_nodes(nodes_created)
# fix the problem of z_order for backdrops
self._fix_z_order(placeholder)
if placeholder.data.get("keep_placeholder"):
self._imprint_siblings(placeholder)
if placeholder.data["nb_children"] == 0:
# save initial nodes positions and dimensions, update them
# and set inputs and outputs of created nodes
if placeholder.data.get("keep_placeholder"):
self._imprint_inits()
self._update_nodes(placeholder, nuke.allNodes(), nodes_created)
self._set_created_connections(placeholder)
elif placeholder.data["siblings"]:
# create copies of placeholder siblings for the new created nodes,
# set their inputs and outputs and update all nodes positions and
# dimensions and siblings names
siblings = get_nodes_by_names(placeholder.data["siblings"])
refresh_nodes(siblings)
copies = self._create_sib_copies(placeholder)
new_nodes = list(copies.values()) # copies nodes
self._update_nodes(new_nodes, nodes_created)
placeholder_node.removeKnob(placeholder_node.knob("siblings"))
new_nodes_name = get_names_from_nodes(new_nodes)
imprint(placeholder_node, {"siblings": new_nodes_name})
self._set_copies_connections(placeholder, copies)
self._update_nodes(
nuke.allNodes(),
new_nodes + nodes_created,
20
)
new_siblings = get_names_from_nodes(new_nodes)
placeholder.data["siblings"] = new_siblings
else:
# if the placeholder doesn't have siblings, the created
# nodes will be placed in a free space
xpointer, ypointer = find_free_space_to_paste_nodes(
nodes_created, direction="bottom", offset=200
)
node = nuke.createNode("NoOp")
reset_selection()
nuke.delete(node)
for node in nodes_created:
xpos = (node.xpos() - min_x) + xpointer
ypos = (node.ypos() - min_y) + ypointer
node.setXYpos(xpos, ypos)
placeholder.data["nb_children"] += 1
reset_selection()
# go back to root group
nuke.root().begin()
def _move_to_placeholder_group(self, placeholder, nodes_created):
"""
opening the placeholder's group and copying created nodes in it.
Returns :
nodes_created (list): the new list of pasted nodes
"""
groups_name = placeholder.data["group_name"]
reset_selection()
select_nodes(nodes_created)
if groups_name:
with node_tempfile() as filepath:
nuke.nodeCopy(filepath)
for node in nuke.selectedNodes():
nuke.delete(node)
group = nuke.toNode(groups_name)
group.begin()
nuke.nodePaste(filepath)
nodes_created = nuke.selectedNodes()
return nodes_created
def _fix_z_order(self, placeholder):
"""Fix the problem of z_order when a backdrop is create."""
nodes_created = placeholder.data["last_created"]
created_backdrops = []
bd_orders = set()
for node in nodes_created:
if isinstance(node, nuke.BackdropNode):
created_backdrops.append(node)
bd_orders.add(node.knob("z_order").getValue())
if not bd_orders:
return
sib_orders = set()
for node_name in placeholder.data["siblings"]:
node = nuke.toNode(node_name)
if isinstance(node, nuke.BackdropNode):
sib_orders.add(node.knob("z_order").getValue())
if not sib_orders:
return
min_order = min(bd_orders)
max_order = max(sib_orders)
for backdrop_node in created_backdrops:
z_order = backdrop_node.knob("z_order").getValue()
backdrop_node.knob("z_order").setValue(
z_order + max_order - min_order + 1)
def _imprint_siblings(self, placeholder):
"""
- add siblings names to placeholder attributes (nodes created with it)
- add Id to the attributes of all the other nodes
"""
created_nodes = placeholder.data["last_created"]
created_nodes_set = set(created_nodes)
for node in created_nodes:
node_knobs = node.knobs()
if (
"is_placeholder" not in node_knobs
or (
"is_placeholder" in node_knobs
and node.knob("is_placeholder").value()
)
):
siblings = list(created_nodes_set - {node})
siblings_name = get_names_from_nodes(siblings)
siblings = {"siblings": siblings_name}
imprint(node, siblings)
def _imprint_inits(self):
"""Add initial positions and dimensions to the attributes"""
for node in nuke.allNodes():
refresh_node(node)
imprint(node, {"x_init": node.xpos(), "y_init": node.ypos()})
node.knob("x_init").setVisible(False)
node.knob("y_init").setVisible(False)
width = node.screenWidth()
height = node.screenHeight()
if "bdwidth" in node.knobs():
imprint(node, {"w_init": width, "h_init": height})
node.knob("w_init").setVisible(False)
node.knob("h_init").setVisible(False)
refresh_node(node)
def _update_nodes(
self, placeholder, nodes, considered_nodes, offset_y=None
):
"""Adjust backdrop nodes dimensions and positions.
Considering some nodes sizes.
Args:
nodes (list): list of nodes to update
considered_nodes (list): list of nodes to consider while updating
positions and dimensions
offset (int): distance between copies
"""
placeholder_node = nuke.toNode(placeholder.scene_identifier)
min_x, min_y, max_x, max_y = get_extreme_positions(considered_nodes)
diff_x = diff_y = 0
contained_nodes = [] # for backdrops
if offset_y is None:
width_ph = placeholder_node.screenWidth()
height_ph = placeholder_node.screenHeight()
diff_y = max_y - min_y - height_ph
diff_x = max_x - min_x - width_ph
contained_nodes = [placeholder_node]
min_x = placeholder_node.xpos()
min_y = placeholder_node.ypos()
else:
siblings = get_nodes_by_names(placeholder.data["siblings"])
minX, _, maxX, _ = get_extreme_positions(siblings)
diff_y = max_y - min_y + 20
diff_x = abs(max_x - min_x - maxX + minX)
contained_nodes = considered_nodes
if diff_y <= 0 and diff_x <= 0:
return
for node in nodes:
refresh_node(node)
if (
node == placeholder_node
or node in considered_nodes
):
continue
if (
not isinstance(node, nuke.BackdropNode)
or (
isinstance(node, nuke.BackdropNode)
and not set(contained_nodes) <= set(node.getNodes())
)
):
if offset_y is None and node.xpos() >= min_x:
node.setXpos(node.xpos() + diff_x)
if node.ypos() >= min_y:
node.setYpos(node.ypos() + diff_y)
else:
width = node.screenWidth()
height = node.screenHeight()
node.knob("bdwidth").setValue(width + diff_x)
node.knob("bdheight").setValue(height + diff_y)
refresh_node(node)
def _set_created_connections(self, placeholder):
"""
set inputs and outputs of created nodes"""
placeholder_node = nuke.toNode(placeholder.scene_identifier)
input_node, output_node = get_group_io_nodes(
placeholder.data["last_created"]
)
for node in placeholder_node.dependent():
for idx in range(node.inputs()):
if node.input(idx) == placeholder_node and output_node:
node.setInput(idx, output_node)
for node in placeholder_node.dependencies():
for idx in range(placeholder_node.inputs()):
if placeholder_node.input(idx) == node and input_node:
input_node.setInput(0, node)
def _create_sib_copies(self, placeholder):
""" creating copies of the palce_holder siblings (the ones who were
created with it) for the new nodes added
Returns :
copies (dict) : with copied nodes names and their copies
"""
copies = {}
siblings = get_nodes_by_names(placeholder.data["siblings"])
for node in siblings:
new_node = duplicate_node(node)
x_init = int(new_node.knob("x_init").getValue())
y_init = int(new_node.knob("y_init").getValue())
new_node.setXYpos(x_init, y_init)
if isinstance(new_node, nuke.BackdropNode):
w_init = new_node.knob("w_init").getValue()
h_init = new_node.knob("h_init").getValue()
new_node.knob("bdwidth").setValue(w_init)
new_node.knob("bdheight").setValue(h_init)
refresh_node(node)
if "repre_id" in node.knobs().keys():
node.removeKnob(node.knob("repre_id"))
copies[node.name()] = new_node
return copies
def _set_copies_connections(self, placeholder, copies):
"""Set inputs and outputs of the copies.
Args:
copies (dict): Copied nodes by their names.
"""
last_input, last_output = get_group_io_nodes(
placeholder.data["last_created"]
)
siblings = get_nodes_by_names(placeholder.data["siblings"])
siblings_input, siblings_output = get_group_io_nodes(siblings)
copy_input = copies[siblings_input.name()]
copy_output = copies[siblings_output.name()]
for node_init in siblings:
if node_init == siblings_output:
continue
node_copy = copies[node_init.name()]
for node in node_init.dependent():
for idx in range(node.inputs()):
if node.input(idx) != node_init:
continue
if node in siblings:
copies[node.name()].setInput(idx, node_copy)
else:
last_input.setInput(0, node_copy)
for node in node_init.dependencies():
for idx in range(node_init.inputs()):
if node_init.input(idx) != node:
continue
if node_init == siblings_input:
copy_input.setInput(idx, node)
elif node in siblings:
node_copy.setInput(idx, copies[node.name()])
else:
node_copy.setInput(idx, last_output)
siblings_input.setInput(0, copy_output)
def build_workfile_template(*args, **kwargs):
builder = NukeTemplateBuilder(registered_host())
builder.build_template(*args, **kwargs)

View file

@ -0,0 +1,428 @@
import nuke
from ayon_core.pipeline.workfile.workfile_template_builder import (
CreatePlaceholderItem,
PlaceholderCreateMixin,
)
from ayon_core.hosts.nuke.api.lib import (
find_free_space_to_paste_nodes,
get_extreme_positions,
get_group_io_nodes,
imprint,
refresh_node,
refresh_nodes,
reset_selection,
get_names_from_nodes,
get_nodes_by_names,
select_nodes,
duplicate_node,
node_tempfile,
)
from ayon_core.hosts.nuke.api.workfile_template_builder import (
NukePlaceholderPlugin
)
class NukePlaceholderCreatePlugin(
NukePlaceholderPlugin, PlaceholderCreateMixin
):
identifier = "nuke.create"
label = "Nuke create"
def _parse_placeholder_node_data(self, node):
placeholder_data = super(
NukePlaceholderCreatePlugin, self
)._parse_placeholder_node_data(node)
node_knobs = node.knobs()
nb_children = 0
if "nb_children" in node_knobs:
nb_children = int(node_knobs["nb_children"].getValue())
placeholder_data["nb_children"] = nb_children
siblings = []
if "siblings" in node_knobs:
siblings = node_knobs["siblings"].values()
placeholder_data["siblings"] = siblings
node_full_name = node.fullName()
placeholder_data["group_name"] = node_full_name.rpartition(".")[0]
placeholder_data["last_loaded"] = []
placeholder_data["delete"] = False
return placeholder_data
def _before_instance_create(self, placeholder):
placeholder.data["nodes_init"] = nuke.allNodes()
def collect_placeholders(self):
output = []
scene_placeholders = self._collect_scene_placeholders()
for node_name, node in scene_placeholders.items():
plugin_identifier_knob = node.knob("plugin_identifier")
if (
plugin_identifier_knob is None
or plugin_identifier_knob.getValue() != self.identifier
):
continue
placeholder_data = self._parse_placeholder_node_data(node)
output.append(
CreatePlaceholderItem(node_name, placeholder_data, self)
)
return output
def populate_placeholder(self, placeholder):
self.populate_create_placeholder(placeholder)
def repopulate_placeholder(self, placeholder):
self.populate_create_placeholder(placeholder)
def get_placeholder_options(self, options=None):
return self.get_create_plugin_options(options)
def post_placeholder_process(self, placeholder, failed):
"""Cleanup placeholder after load of its corresponding representations.
Args:
placeholder (PlaceholderItem): Item which was just used to load
representation.
failed (bool): Loading of representation failed.
"""
# deselect all selected nodes
placeholder_node = nuke.toNode(placeholder.scene_identifier)
# getting the latest nodes added
nodes_init = placeholder.data["nodes_init"]
nodes_created = list(set(nuke.allNodes()) - set(nodes_init))
self.log.debug("Created nodes: {}".format(nodes_created))
if not nodes_created:
return
placeholder.data["delete"] = True
nodes_created = self._move_to_placeholder_group(
placeholder, nodes_created
)
placeholder.data["last_created"] = nodes_created
refresh_nodes(nodes_created)
# positioning of the created nodes
min_x, min_y, _, _ = get_extreme_positions(nodes_created)
for node in nodes_created:
xpos = (node.xpos() - min_x) + placeholder_node.xpos()
ypos = (node.ypos() - min_y) + placeholder_node.ypos()
node.setXYpos(xpos, ypos)
refresh_nodes(nodes_created)
# fix the problem of z_order for backdrops
self._fix_z_order(placeholder)
if placeholder.data.get("keep_placeholder"):
self._imprint_siblings(placeholder)
if placeholder.data["nb_children"] == 0:
# save initial nodes positions and dimensions, update them
# and set inputs and outputs of created nodes
if placeholder.data.get("keep_placeholder"):
self._imprint_inits()
self._update_nodes(placeholder, nuke.allNodes(), nodes_created)
self._set_created_connections(placeholder)
elif placeholder.data["siblings"]:
# create copies of placeholder siblings for the new created nodes,
# set their inputs and outputs and update all nodes positions and
# dimensions and siblings names
siblings = get_nodes_by_names(placeholder.data["siblings"])
refresh_nodes(siblings)
copies = self._create_sib_copies(placeholder)
new_nodes = list(copies.values()) # copies nodes
self._update_nodes(new_nodes, nodes_created)
placeholder_node.removeKnob(placeholder_node.knob("siblings"))
new_nodes_name = get_names_from_nodes(new_nodes)
imprint(placeholder_node, {"siblings": new_nodes_name})
self._set_copies_connections(placeholder, copies)
self._update_nodes(
nuke.allNodes(),
new_nodes + nodes_created,
20
)
new_siblings = get_names_from_nodes(new_nodes)
placeholder.data["siblings"] = new_siblings
else:
# if the placeholder doesn't have siblings, the created
# nodes will be placed in a free space
xpointer, ypointer = find_free_space_to_paste_nodes(
nodes_created, direction="bottom", offset=200
)
node = nuke.createNode("NoOp")
reset_selection()
nuke.delete(node)
for node in nodes_created:
xpos = (node.xpos() - min_x) + xpointer
ypos = (node.ypos() - min_y) + ypointer
node.setXYpos(xpos, ypos)
placeholder.data["nb_children"] += 1
reset_selection()
# go back to root group
nuke.root().begin()
def _move_to_placeholder_group(self, placeholder, nodes_created):
"""
opening the placeholder's group and copying created nodes in it.
Returns :
nodes_created (list): the new list of pasted nodes
"""
groups_name = placeholder.data["group_name"]
reset_selection()
select_nodes(nodes_created)
if groups_name:
with node_tempfile() as filepath:
nuke.nodeCopy(filepath)
for node in nuke.selectedNodes():
nuke.delete(node)
group = nuke.toNode(groups_name)
group.begin()
nuke.nodePaste(filepath)
nodes_created = nuke.selectedNodes()
return nodes_created
def _fix_z_order(self, placeholder):
"""Fix the problem of z_order when a backdrop is create."""
nodes_created = placeholder.data["last_created"]
created_backdrops = []
bd_orders = set()
for node in nodes_created:
if isinstance(node, nuke.BackdropNode):
created_backdrops.append(node)
bd_orders.add(node.knob("z_order").getValue())
if not bd_orders:
return
sib_orders = set()
for node_name in placeholder.data["siblings"]:
node = nuke.toNode(node_name)
if isinstance(node, nuke.BackdropNode):
sib_orders.add(node.knob("z_order").getValue())
if not sib_orders:
return
min_order = min(bd_orders)
max_order = max(sib_orders)
for backdrop_node in created_backdrops:
z_order = backdrop_node.knob("z_order").getValue()
backdrop_node.knob("z_order").setValue(
z_order + max_order - min_order + 1)
def _imprint_siblings(self, placeholder):
"""
- add siblings names to placeholder attributes (nodes created with it)
- add Id to the attributes of all the other nodes
"""
created_nodes = placeholder.data["last_created"]
created_nodes_set = set(created_nodes)
for node in created_nodes:
node_knobs = node.knobs()
if (
"is_placeholder" not in node_knobs
or (
"is_placeholder" in node_knobs
and node.knob("is_placeholder").value()
)
):
siblings = list(created_nodes_set - {node})
siblings_name = get_names_from_nodes(siblings)
siblings = {"siblings": siblings_name}
imprint(node, siblings)
def _imprint_inits(self):
"""Add initial positions and dimensions to the attributes"""
for node in nuke.allNodes():
refresh_node(node)
imprint(node, {"x_init": node.xpos(), "y_init": node.ypos()})
node.knob("x_init").setVisible(False)
node.knob("y_init").setVisible(False)
width = node.screenWidth()
height = node.screenHeight()
if "bdwidth" in node.knobs():
imprint(node, {"w_init": width, "h_init": height})
node.knob("w_init").setVisible(False)
node.knob("h_init").setVisible(False)
refresh_node(node)
def _update_nodes(
self, placeholder, nodes, considered_nodes, offset_y=None
):
"""Adjust backdrop nodes dimensions and positions.
Considering some nodes sizes.
Args:
nodes (list): list of nodes to update
considered_nodes (list): list of nodes to consider while updating
positions and dimensions
offset (int): distance between copies
"""
placeholder_node = nuke.toNode(placeholder.scene_identifier)
min_x, min_y, max_x, max_y = get_extreme_positions(considered_nodes)
diff_x = diff_y = 0
contained_nodes = [] # for backdrops
if offset_y is None:
width_ph = placeholder_node.screenWidth()
height_ph = placeholder_node.screenHeight()
diff_y = max_y - min_y - height_ph
diff_x = max_x - min_x - width_ph
contained_nodes = [placeholder_node]
min_x = placeholder_node.xpos()
min_y = placeholder_node.ypos()
else:
siblings = get_nodes_by_names(placeholder.data["siblings"])
minX, _, maxX, _ = get_extreme_positions(siblings)
diff_y = max_y - min_y + 20
diff_x = abs(max_x - min_x - maxX + minX)
contained_nodes = considered_nodes
if diff_y <= 0 and diff_x <= 0:
return
for node in nodes:
refresh_node(node)
if (
node == placeholder_node
or node in considered_nodes
):
continue
if (
not isinstance(node, nuke.BackdropNode)
or (
isinstance(node, nuke.BackdropNode)
and not set(contained_nodes) <= set(node.getNodes())
)
):
if offset_y is None and node.xpos() >= min_x:
node.setXpos(node.xpos() + diff_x)
if node.ypos() >= min_y:
node.setYpos(node.ypos() + diff_y)
else:
width = node.screenWidth()
height = node.screenHeight()
node.knob("bdwidth").setValue(width + diff_x)
node.knob("bdheight").setValue(height + diff_y)
refresh_node(node)
def _set_created_connections(self, placeholder):
"""
set inputs and outputs of created nodes"""
placeholder_node = nuke.toNode(placeholder.scene_identifier)
input_node, output_node = get_group_io_nodes(
placeholder.data["last_created"]
)
for node in placeholder_node.dependent():
for idx in range(node.inputs()):
if node.input(idx) == placeholder_node and output_node:
node.setInput(idx, output_node)
for node in placeholder_node.dependencies():
for idx in range(placeholder_node.inputs()):
if placeholder_node.input(idx) == node and input_node:
input_node.setInput(0, node)
def _create_sib_copies(self, placeholder):
""" creating copies of the palce_holder siblings (the ones who were
created with it) for the new nodes added
Returns :
copies (dict) : with copied nodes names and their copies
"""
copies = {}
siblings = get_nodes_by_names(placeholder.data["siblings"])
for node in siblings:
new_node = duplicate_node(node)
x_init = int(new_node.knob("x_init").getValue())
y_init = int(new_node.knob("y_init").getValue())
new_node.setXYpos(x_init, y_init)
if isinstance(new_node, nuke.BackdropNode):
w_init = new_node.knob("w_init").getValue()
h_init = new_node.knob("h_init").getValue()
new_node.knob("bdwidth").setValue(w_init)
new_node.knob("bdheight").setValue(h_init)
refresh_node(node)
if "repre_id" in node.knobs().keys():
node.removeKnob(node.knob("repre_id"))
copies[node.name()] = new_node
return copies
def _set_copies_connections(self, placeholder, copies):
"""Set inputs and outputs of the copies.
Args:
copies (dict): Copied nodes by their names.
"""
last_input, last_output = get_group_io_nodes(
placeholder.data["last_created"]
)
siblings = get_nodes_by_names(placeholder.data["siblings"])
siblings_input, siblings_output = get_group_io_nodes(siblings)
copy_input = copies[siblings_input.name()]
copy_output = copies[siblings_output.name()]
for node_init in siblings:
if node_init == siblings_output:
continue
node_copy = copies[node_init.name()]
for node in node_init.dependent():
for idx in range(node.inputs()):
if node.input(idx) != node_init:
continue
if node in siblings:
copies[node.name()].setInput(idx, node_copy)
else:
last_input.setInput(0, node_copy)
for node in node_init.dependencies():
for idx in range(node_init.inputs()):
if node_init.input(idx) != node:
continue
if node_init == siblings_input:
copy_input.setInput(idx, node)
elif node in siblings:
node_copy.setInput(idx, copies[node.name()])
else:
node_copy.setInput(idx, last_output)
siblings_input.setInput(0, copy_output)

View file

@ -0,0 +1,455 @@
import nuke
from ayon_core.pipeline.workfile.workfile_template_builder import (
LoadPlaceholderItem,
PlaceholderLoadMixin,
)
from ayon_core.hosts.nuke.api.lib import (
find_free_space_to_paste_nodes,
get_extreme_positions,
get_group_io_nodes,
imprint,
refresh_node,
refresh_nodes,
reset_selection,
get_names_from_nodes,
get_nodes_by_names,
select_nodes,
duplicate_node,
node_tempfile,
)
from ayon_core.hosts.nuke.api.workfile_template_builder import (
NukePlaceholderPlugin
)
class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin):
identifier = "nuke.load"
label = "Nuke load"
def _parse_placeholder_node_data(self, node):
placeholder_data = super(
NukePlaceholderLoadPlugin, self
)._parse_placeholder_node_data(node)
node_knobs = node.knobs()
nb_children = 0
if "nb_children" in node_knobs:
nb_children = int(node_knobs["nb_children"].getValue())
placeholder_data["nb_children"] = nb_children
siblings = []
if "siblings" in node_knobs:
siblings = node_knobs["siblings"].values()
placeholder_data["siblings"] = siblings
node_full_name = node.fullName()
placeholder_data["group_name"] = node_full_name.rpartition(".")[0]
placeholder_data["last_loaded"] = []
placeholder_data["delete"] = False
return placeholder_data
def _get_loaded_repre_ids(self):
loaded_representation_ids = self.builder.get_shared_populate_data(
"loaded_representation_ids"
)
if loaded_representation_ids is None:
loaded_representation_ids = set()
for node in nuke.allNodes():
if "repre_id" in node.knobs():
loaded_representation_ids.add(
node.knob("repre_id").getValue()
)
self.builder.set_shared_populate_data(
"loaded_representation_ids", loaded_representation_ids
)
return loaded_representation_ids
def _before_placeholder_load(self, placeholder):
placeholder.data["nodes_init"] = nuke.allNodes()
def _before_repre_load(self, placeholder, representation):
placeholder.data["last_repre_id"] = representation["id"]
def collect_placeholders(self):
output = []
scene_placeholders = self._collect_scene_placeholders()
for node_name, node in scene_placeholders.items():
plugin_identifier_knob = node.knob("plugin_identifier")
if (
plugin_identifier_knob is None
or plugin_identifier_knob.getValue() != self.identifier
):
continue
placeholder_data = self._parse_placeholder_node_data(node)
# TODO do data validations and maybe updgrades if are invalid
output.append(
LoadPlaceholderItem(node_name, placeholder_data, self)
)
return output
def populate_placeholder(self, placeholder):
self.populate_load_placeholder(placeholder)
def repopulate_placeholder(self, placeholder):
repre_ids = self._get_loaded_repre_ids()
self.populate_load_placeholder(placeholder, repre_ids)
def get_placeholder_options(self, options=None):
return self.get_load_plugin_options(options)
def post_placeholder_process(self, placeholder, failed):
"""Cleanup placeholder after load of its corresponding representations.
Args:
placeholder (PlaceholderItem): Item which was just used to load
representation.
failed (bool): Loading of representation failed.
"""
# deselect all selected nodes
placeholder_node = nuke.toNode(placeholder.scene_identifier)
# getting the latest nodes added
# TODO get from shared populate data!
nodes_init = placeholder.data["nodes_init"]
nodes_loaded = list(set(nuke.allNodes()) - set(nodes_init))
self.log.debug("Loaded nodes: {}".format(nodes_loaded))
if not nodes_loaded:
return
placeholder.data["delete"] = True
nodes_loaded = self._move_to_placeholder_group(
placeholder, nodes_loaded
)
placeholder.data["last_loaded"] = nodes_loaded
refresh_nodes(nodes_loaded)
# positioning of the loaded nodes
min_x, min_y, _, _ = get_extreme_positions(nodes_loaded)
for node in nodes_loaded:
xpos = (node.xpos() - min_x) + placeholder_node.xpos()
ypos = (node.ypos() - min_y) + placeholder_node.ypos()
node.setXYpos(xpos, ypos)
refresh_nodes(nodes_loaded)
# fix the problem of z_order for backdrops
self._fix_z_order(placeholder)
if placeholder.data.get("keep_placeholder"):
self._imprint_siblings(placeholder)
if placeholder.data["nb_children"] == 0:
# save initial nodes positions and dimensions, update them
# and set inputs and outputs of loaded nodes
if placeholder.data.get("keep_placeholder"):
self._imprint_inits()
self._update_nodes(placeholder, nuke.allNodes(), nodes_loaded)
self._set_loaded_connections(placeholder)
elif placeholder.data["siblings"]:
# create copies of placeholder siblings for the new loaded nodes,
# set their inputs and outputs and update all nodes positions and
# dimensions and siblings names
siblings = get_nodes_by_names(placeholder.data["siblings"])
refresh_nodes(siblings)
copies = self._create_sib_copies(placeholder)
new_nodes = list(copies.values()) # copies nodes
self._update_nodes(new_nodes, nodes_loaded)
placeholder_node.removeKnob(placeholder_node.knob("siblings"))
new_nodes_name = get_names_from_nodes(new_nodes)
imprint(placeholder_node, {"siblings": new_nodes_name})
self._set_copies_connections(placeholder, copies)
self._update_nodes(
nuke.allNodes(),
new_nodes + nodes_loaded,
20
)
new_siblings = get_names_from_nodes(new_nodes)
placeholder.data["siblings"] = new_siblings
else:
# if the placeholder doesn't have siblings, the loaded
# nodes will be placed in a free space
xpointer, ypointer = find_free_space_to_paste_nodes(
nodes_loaded, direction="bottom", offset=200
)
node = nuke.createNode("NoOp")
reset_selection()
nuke.delete(node)
for node in nodes_loaded:
xpos = (node.xpos() - min_x) + xpointer
ypos = (node.ypos() - min_y) + ypointer
node.setXYpos(xpos, ypos)
placeholder.data["nb_children"] += 1
reset_selection()
# go back to root group
nuke.root().begin()
def _move_to_placeholder_group(self, placeholder, nodes_loaded):
"""
opening the placeholder's group and copying loaded nodes in it.
Returns :
nodes_loaded (list): the new list of pasted nodes
"""
groups_name = placeholder.data["group_name"]
reset_selection()
select_nodes(nodes_loaded)
if groups_name:
with node_tempfile() as filepath:
nuke.nodeCopy(filepath)
for node in nuke.selectedNodes():
nuke.delete(node)
group = nuke.toNode(groups_name)
group.begin()
nuke.nodePaste(filepath)
nodes_loaded = nuke.selectedNodes()
return nodes_loaded
def _fix_z_order(self, placeholder):
"""Fix the problem of z_order when a backdrop is loaded."""
nodes_loaded = placeholder.data["last_loaded"]
loaded_backdrops = []
bd_orders = set()
for node in nodes_loaded:
if isinstance(node, nuke.BackdropNode):
loaded_backdrops.append(node)
bd_orders.add(node.knob("z_order").getValue())
if not bd_orders:
return
sib_orders = set()
for node_name in placeholder.data["siblings"]:
node = nuke.toNode(node_name)
if isinstance(node, nuke.BackdropNode):
sib_orders.add(node.knob("z_order").getValue())
if not sib_orders:
return
min_order = min(bd_orders)
max_order = max(sib_orders)
for backdrop_node in loaded_backdrops:
z_order = backdrop_node.knob("z_order").getValue()
backdrop_node.knob("z_order").setValue(
z_order + max_order - min_order + 1)
def _imprint_siblings(self, placeholder):
"""
- add siblings names to placeholder attributes (nodes loaded with it)
- add Id to the attributes of all the other nodes
"""
loaded_nodes = placeholder.data["last_loaded"]
loaded_nodes_set = set(loaded_nodes)
data = {"repre_id": str(placeholder.data["last_repre_id"])}
for node in loaded_nodes:
node_knobs = node.knobs()
if "builder_type" not in node_knobs:
# save the id of representation for all imported nodes
imprint(node, data)
node.knob("repre_id").setVisible(False)
refresh_node(node)
continue
if (
"is_placeholder" not in node_knobs
or (
"is_placeholder" in node_knobs
and node.knob("is_placeholder").value()
)
):
siblings = list(loaded_nodes_set - {node})
siblings_name = get_names_from_nodes(siblings)
siblings = {"siblings": siblings_name}
imprint(node, siblings)
def _imprint_inits(self):
"""Add initial positions and dimensions to the attributes"""
for node in nuke.allNodes():
refresh_node(node)
imprint(node, {"x_init": node.xpos(), "y_init": node.ypos()})
node.knob("x_init").setVisible(False)
node.knob("y_init").setVisible(False)
width = node.screenWidth()
height = node.screenHeight()
if "bdwidth" in node.knobs():
imprint(node, {"w_init": width, "h_init": height})
node.knob("w_init").setVisible(False)
node.knob("h_init").setVisible(False)
refresh_node(node)
def _update_nodes(
self, placeholder, nodes, considered_nodes, offset_y=None
):
"""Adjust backdrop nodes dimensions and positions.
Considering some nodes sizes.
Args:
nodes (list): list of nodes to update
considered_nodes (list): list of nodes to consider while updating
positions and dimensions
offset (int): distance between copies
"""
placeholder_node = nuke.toNode(placeholder.scene_identifier)
min_x, min_y, max_x, max_y = get_extreme_positions(considered_nodes)
diff_x = diff_y = 0
contained_nodes = [] # for backdrops
if offset_y is None:
width_ph = placeholder_node.screenWidth()
height_ph = placeholder_node.screenHeight()
diff_y = max_y - min_y - height_ph
diff_x = max_x - min_x - width_ph
contained_nodes = [placeholder_node]
min_x = placeholder_node.xpos()
min_y = placeholder_node.ypos()
else:
siblings = get_nodes_by_names(placeholder.data["siblings"])
minX, _, maxX, _ = get_extreme_positions(siblings)
diff_y = max_y - min_y + 20
diff_x = abs(max_x - min_x - maxX + minX)
contained_nodes = considered_nodes
if diff_y <= 0 and diff_x <= 0:
return
for node in nodes:
refresh_node(node)
if (
node == placeholder_node
or node in considered_nodes
):
continue
if (
not isinstance(node, nuke.BackdropNode)
or (
isinstance(node, nuke.BackdropNode)
and not set(contained_nodes) <= set(node.getNodes())
)
):
if offset_y is None and node.xpos() >= min_x:
node.setXpos(node.xpos() + diff_x)
if node.ypos() >= min_y:
node.setYpos(node.ypos() + diff_y)
else:
width = node.screenWidth()
height = node.screenHeight()
node.knob("bdwidth").setValue(width + diff_x)
node.knob("bdheight").setValue(height + diff_y)
refresh_node(node)
def _set_loaded_connections(self, placeholder):
"""
set inputs and outputs of loaded nodes"""
placeholder_node = nuke.toNode(placeholder.scene_identifier)
input_node, output_node = get_group_io_nodes(
placeholder.data["last_loaded"]
)
for node in placeholder_node.dependent():
for idx in range(node.inputs()):
if node.input(idx) == placeholder_node and output_node:
node.setInput(idx, output_node)
for node in placeholder_node.dependencies():
for idx in range(placeholder_node.inputs()):
if placeholder_node.input(idx) == node and input_node:
input_node.setInput(0, node)
def _create_sib_copies(self, placeholder):
""" creating copies of the palce_holder siblings (the ones who were
loaded with it) for the new nodes added
Returns :
copies (dict) : with copied nodes names and their copies
"""
copies = {}
siblings = get_nodes_by_names(placeholder.data["siblings"])
for node in siblings:
new_node = duplicate_node(node)
x_init = int(new_node.knob("x_init").getValue())
y_init = int(new_node.knob("y_init").getValue())
new_node.setXYpos(x_init, y_init)
if isinstance(new_node, nuke.BackdropNode):
w_init = new_node.knob("w_init").getValue()
h_init = new_node.knob("h_init").getValue()
new_node.knob("bdwidth").setValue(w_init)
new_node.knob("bdheight").setValue(h_init)
refresh_node(node)
if "repre_id" in node.knobs().keys():
node.removeKnob(node.knob("repre_id"))
copies[node.name()] = new_node
return copies
def _set_copies_connections(self, placeholder, copies):
"""Set inputs and outputs of the copies.
Args:
copies (dict): Copied nodes by their names.
"""
last_input, last_output = get_group_io_nodes(
placeholder.data["last_loaded"]
)
siblings = get_nodes_by_names(placeholder.data["siblings"])
siblings_input, siblings_output = get_group_io_nodes(siblings)
copy_input = copies[siblings_input.name()]
copy_output = copies[siblings_output.name()]
for node_init in siblings:
if node_init == siblings_output:
continue
node_copy = copies[node_init.name()]
for node in node_init.dependent():
for idx in range(node.inputs()):
if node.input(idx) != node_init:
continue
if node in siblings:
copies[node.name()].setInput(idx, node_copy)
else:
last_input.setInput(0, node_copy)
for node in node_init.dependencies():
for idx in range(node_init.inputs()):
if node_init.input(idx) != node:
continue
if node_init == siblings_input:
copy_input.setInput(idx, node)
elif node in siblings:
node_copy.setInput(idx, copies[node.name()])
else:
node_copy.setInput(idx, last_output)
siblings_input.setInput(0, copy_output)

View file

@ -586,7 +586,6 @@ def prompt_new_file_with_mesh(mesh_filepath):
# TODO: find a way to improve the process event to
# load more complicated mesh
app.processEvents(QtCore.QEventLoop.ExcludeUserInputEvents, 3000)
file_dialog.done(file_dialog.Accepted)
app.processEvents(QtCore.QEventLoop.AllEvents)
@ -606,7 +605,7 @@ def prompt_new_file_with_mesh(mesh_filepath):
mesh_select.setVisible(False)
# Ensure UI is visually up-to-date
app.processEvents(QtCore.QEventLoop.ExcludeUserInputEvents)
app.processEvents(QtCore.QEventLoop.ExcludeUserInputEvents, 8000)
# Trigger the 'select file' dialog to set the path and have the
# new file dialog to use the path.
@ -623,8 +622,6 @@ def prompt_new_file_with_mesh(mesh_filepath):
"Failed to set mesh path with the prompt dialog:"
f"{mesh_filepath}\n\n"
"Creating new project directly with the mesh path instead.")
else:
dialog.done(dialog.Accepted)
new_action = _get_new_project_action()
if not new_action:

View file

@ -1,3 +1,5 @@
import copy
from qtpy import QtWidgets, QtCore
from ayon_core.pipeline import (
load,
get_representation_path,
@ -8,10 +10,133 @@ from ayon_core.hosts.substancepainter.api.pipeline import (
set_container_metadata,
remove_container_metadata
)
from ayon_core.hosts.substancepainter.api.lib import prompt_new_file_with_mesh
import substance_painter.project
import qargparse
def _convert(substance_attr):
"""Return Substance Painter Python API Project attribute from string.
This converts a string like "ProjectWorkflow.Default" to for example
the Substance Painter Python API equivalent object, like:
`substance_painter.project.ProjectWorkflow.Default`
Args:
substance_attr (str): The `substance_painter.project` attribute,
for example "ProjectWorkflow.Default"
Returns:
Any: Substance Python API object of the project attribute.
Raises:
ValueError: If attribute does not exist on the
`substance_painter.project` python api.
"""
root = substance_painter.project
for attr in substance_attr.split("."):
root = getattr(root, attr, None)
if root is None:
raise ValueError(
"Substance Painter project attribute"
f" does not exist: {substance_attr}")
return root
def get_template_by_name(name: str, templates: list[dict]) -> dict:
return next(
template for template in templates
if template["name"] == name
)
class SubstanceProjectConfigurationWindow(QtWidgets.QDialog):
"""The pop-up dialog allows users to choose material
duplicate options for importing Max objects when updating
or switching assets.
"""
def __init__(self, project_templates):
super(SubstanceProjectConfigurationWindow, self).__init__()
self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint)
self.configuration = None
self.template_names = [template["name"] for template
in project_templates]
self.project_templates = project_templates
self.widgets = {
"label": QtWidgets.QLabel(
"Select your template for project configuration"),
"template_options": QtWidgets.QComboBox(),
"import_cameras": QtWidgets.QCheckBox("Import Cameras"),
"preserve_strokes": QtWidgets.QCheckBox("Preserve Strokes"),
"clickbox": QtWidgets.QWidget(),
"combobox": QtWidgets.QWidget(),
"buttons": QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.Ok
| QtWidgets.QDialogButtonBox.Cancel)
}
self.widgets["template_options"].addItems(self.template_names)
template_name = self.widgets["template_options"].currentText()
self._update_to_match_template(template_name)
# Build clickboxes
layout = QtWidgets.QHBoxLayout(self.widgets["clickbox"])
layout.addWidget(self.widgets["import_cameras"])
layout.addWidget(self.widgets["preserve_strokes"])
# Build combobox
layout = QtWidgets.QHBoxLayout(self.widgets["combobox"])
layout.addWidget(self.widgets["template_options"])
# Build buttons
layout = QtWidgets.QHBoxLayout(self.widgets["buttons"])
# Build layout.
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(self.widgets["label"])
layout.addWidget(self.widgets["combobox"])
layout.addWidget(self.widgets["clickbox"])
layout.addWidget(self.widgets["buttons"])
self.widgets["template_options"].currentTextChanged.connect(
self._update_to_match_template)
self.widgets["buttons"].accepted.connect(self.on_accept)
self.widgets["buttons"].rejected.connect(self.on_reject)
def on_accept(self):
self.configuration = self.get_project_configuration()
self.close()
def on_reject(self):
self.close()
def _update_to_match_template(self, template_name):
template = get_template_by_name(template_name, self.project_templates)
self.widgets["import_cameras"].setChecked(template["import_cameras"])
self.widgets["preserve_strokes"].setChecked(
template["preserve_strokes"])
def get_project_configuration(self):
templates = self.project_templates
template_name = self.widgets["template_options"].currentText()
template = get_template_by_name(template_name, templates)
template = copy.deepcopy(template) # do not edit the original
template["import_cameras"] = self.widgets["import_cameras"].isChecked()
template["preserve_strokes"] = (
self.widgets["preserve_strokes"].isChecked()
)
for key in ["normal_map_format",
"project_workflow",
"tangent_space_mode"]:
template[key] = _convert(template[key])
return template
@classmethod
def prompt(cls, templates):
dialog = cls(templates)
dialog.exec_()
configuration = dialog.configuration
dialog.deleteLater()
return configuration
class SubstanceLoadProjectMesh(load.LoaderPlugin):
@ -25,48 +150,35 @@ class SubstanceLoadProjectMesh(load.LoaderPlugin):
icon = "code-fork"
color = "orange"
options = [
qargparse.Boolean(
"preserve_strokes",
default=True,
help="Preserve strokes positions on mesh.\n"
"(only relevant when loading into existing project)"
),
qargparse.Boolean(
"import_cameras",
default=True,
help="Import cameras from the mesh file."
)
]
# Defined via settings
project_templates = []
def load(self, context, name, namespace, data):
def load(self, context, name, namespace, options=None):
# Get user inputs
import_cameras = data.get("import_cameras", True)
preserve_strokes = data.get("preserve_strokes", True)
sp_settings = substance_painter.project.Settings(
import_cameras=import_cameras
)
result = SubstanceProjectConfigurationWindow.prompt(
self.project_templates)
if not result:
# cancelling loader action
return
if not substance_painter.project.is_open():
# Allow to 'initialize' a new project
path = self.filepath_from_context(context)
# TODO: improve the prompt dialog function to not
# only works for simple polygon scene
result = prompt_new_file_with_mesh(mesh_filepath=path)
if not result:
self.log.info("User cancelled new project prompt."
"Creating new project directly from"
" Substance Painter API Instead.")
settings = substance_painter.project.create(
mesh_file_path=path, settings=sp_settings
)
sp_settings = substance_painter.project.Settings(
import_cameras=result["import_cameras"],
normal_map_format=result["normal_map_format"],
project_workflow=result["project_workflow"],
tangent_space_mode=result["tangent_space_mode"],
default_texture_resolution=result["default_texture_resolution"]
)
settings = substance_painter.project.create(
mesh_file_path=path, settings=sp_settings
)
else:
# Reload the mesh
settings = substance_painter.project.MeshReloadingSettings(
import_cameras=import_cameras,
preserve_strokes=preserve_strokes
)
import_cameras=result["import_cameras"],
preserve_strokes=result["preserve_strokes"])
def on_mesh_reload(status: substance_painter.project.ReloadMeshStatus): # noqa
if status == substance_painter.project.ReloadMeshStatus.SUCCESS: # noqa
@ -92,7 +204,7 @@ class SubstanceLoadProjectMesh(load.LoaderPlugin):
# from the user's original choice. We don't store 'preserve_strokes'
# as we always preserve strokes on updates.
container["options"] = {
"import_cameras": import_cameras,
"import_cameras": result["import_cameras"],
}
set_container_metadata(project_mesh_object_name, container)

View file

@ -1,5 +1,6 @@
import os
from pathlib import Path
from ayon_core.lib import get_ayon_launcher_args
from ayon_core.lib.execute import run_detached_process
from ayon_core.addon import (
@ -57,3 +58,62 @@ def launch():
from ayon_core.tools import traypublisher
traypublisher.main()
@cli_main.command()
@click_wrap.option(
"--filepath",
help="Full path to CSV file with data",
type=str,
required=True
)
@click_wrap.option(
"--project",
help="Project name in which the context will be used",
type=str,
required=True
)
@click_wrap.option(
"--folder-path",
help="Asset name in which the context will be used",
type=str,
required=True
)
@click_wrap.option(
"--task",
help="Task name under Asset in which the context will be used",
type=str,
required=False
)
@click_wrap.option(
"--ignore-validators",
help="Option to ignore validators",
type=bool,
is_flag=True,
required=False
)
def ingestcsv(
filepath,
project,
folder_path,
task,
ignore_validators
):
"""Ingest CSV file into project.
This command will ingest CSV file into project. CSV file must be in
specific format. See documentation for more information.
"""
from .csv_publish import csvpublish
# use Path to check if csv_filepath exists
if not Path(filepath).exists():
raise FileNotFoundError(f"File {filepath} does not exist.")
csvpublish(
filepath,
project,
folder_path,
task,
ignore_validators
)

View file

@ -0,0 +1,86 @@
import os
import pyblish.api
import pyblish.util
from ayon_api import get_folder_by_path, get_task_by_name
from ayon_core.lib.attribute_definitions import FileDefItem
from ayon_core.pipeline import install_host
from ayon_core.pipeline.create import CreateContext
from ayon_core.hosts.traypublisher.api import TrayPublisherHost
def csvpublish(
filepath,
project_name,
folder_path,
task_name=None,
ignore_validators=False
):
"""Publish CSV file.
Args:
filepath (str): Path to CSV file.
project_name (str): Project name.
folder_path (str): Folder path.
task_name (Optional[str]): Task name.
ignore_validators (Optional[bool]): Option to ignore validators.
"""
# initialization of host
host = TrayPublisherHost()
install_host(host)
# setting host context into project
host.set_project_name(project_name)
# form precreate data with field values
file_field = FileDefItem.from_paths([filepath], False).pop().to_dict()
precreate_data = {
"csv_filepath_data": file_field,
}
# create context initialization
create_context = CreateContext(host, headless=True)
folder_entity = get_folder_by_path(
project_name,
folder_path=folder_path,
)
if not folder_entity:
ValueError(
f"Folder path '{folder_path}' doesn't "
f"exists at project '{project_name}'."
)
task_entity = get_task_by_name(
project_name,
folder_entity["id"],
task_name,
)
if not task_entity:
ValueError(
f"Task name '{task_name}' doesn't "
f"exists at folder '{folder_path}'."
)
create_context.create(
"io.ayon.creators.traypublisher.csv_ingest",
"Main",
folder_entity=folder_entity,
task_entity=task_entity,
pre_create_data=precreate_data,
)
# publishing context initialization
pyblish_context = pyblish.api.Context()
pyblish_context.data["create_context"] = create_context
# redefine targets (skip 'local' to disable validators)
if ignore_validators:
targets = ["default", "ingest"]
# publishing
pyblish.util.publish(context=pyblish_context, targets=targets)

View file

@ -0,0 +1,741 @@
import os
import re
import csv
import clique
from io import StringIO
from copy import deepcopy, copy
from ayon_api import get_folder_by_path, get_task_by_name
from ayon_core.pipeline.create import get_product_name
from ayon_core.pipeline import CreatedInstance
from ayon_core.lib import FileDef, BoolDef
from ayon_core.lib.transcoding import (
VIDEO_EXTENSIONS, IMAGE_EXTENSIONS
)
from ayon_core.pipeline.create import CreatorError
from ayon_core.hosts.traypublisher.api.plugin import (
TrayPublishCreator
)
class IngestCSV(TrayPublishCreator):
"""CSV ingest creator class"""
icon = "fa.file"
label = "CSV Ingest"
product_type = "csv_ingest_file"
identifier = "io.ayon.creators.traypublisher.csv_ingest"
default_variants = ["Main"]
description = "Ingest products' data from CSV file"
detailed_description = """
Ingest products' data from CSV file following column and representation
configuration in project settings.
"""
# Position in the list of creators.
order = 10
# settings for this creator
columns_config = {}
representations_config = {}
def create(self, subset_name, instance_data, pre_create_data):
"""Create an product from each row found in the CSV.
Args:
subset_name (str): The subset name.
instance_data (dict): The instance data.
pre_create_data (dict):
"""
csv_filepath_data = pre_create_data.get("csv_filepath_data", {})
folder = csv_filepath_data.get("directory", "")
if not os.path.exists(folder):
raise CreatorError(
f"Directory '{folder}' does not exist."
)
filename = csv_filepath_data.get("filenames", [])
self._process_csv_file(subset_name, instance_data, folder, filename[0])
def _process_csv_file(
self, subset_name, instance_data, staging_dir, filename):
"""Process CSV file.
Args:
subset_name (str): The subset name.
instance_data (dict): The instance data.
staging_dir (str): The staging directory.
filename (str): The filename.
"""
# create new instance from the csv file via self function
self._pass_data_to_csv_instance(
instance_data,
staging_dir,
filename
)
csv_instance = CreatedInstance(
self.product_type, subset_name, instance_data, self
)
self._store_new_instance(csv_instance)
csv_instance["csvFileData"] = {
"filename": filename,
"staging_dir": staging_dir,
}
# from special function get all data from csv file and convert them
# to new instances
csv_data_for_instances = self._get_data_from_csv(
staging_dir, filename)
# create instances from csv data via self function
self._create_instances_from_csv_data(
csv_data_for_instances, staging_dir
)
def _create_instances_from_csv_data(
self,
csv_data_for_instances,
staging_dir
):
"""Create instances from csv data"""
for folder_path, prepared_data in csv_data_for_instances.items():
project_name = self.create_context.get_current_project_name()
products = prepared_data["products"]
for instance_name, product_data in products.items():
# get important instance variables
task_name = product_data["task_name"]
task_type = product_data["task_type"]
variant = product_data["variant"]
product_type = product_data["product_type"]
version = product_data["version"]
# create subset/product name
product_name = get_product_name(
project_name,
task_name,
task_type,
self.host_name,
product_type,
variant
)
# make sure frame start/end is inherited from csv columns
# expected frame range data are handles excluded
for _, repre_data in product_data["representations"].items(): # noqa: E501
frame_start = repre_data["frameStart"]
frame_end = repre_data["frameEnd"]
handle_start = repre_data["handleStart"]
handle_end = repre_data["handleEnd"]
fps = repre_data["fps"]
break
# try to find any version comment in representation data
version_comment = next(
iter(
repre_data["comment"]
for repre_data in product_data["representations"].values() # noqa: E501
if repre_data["comment"]
),
None
)
# try to find any slate switch in representation data
slate_exists = any(
repre_data["slate"]
for _, repre_data in product_data["representations"].items() # noqa: E501
)
# get representations from product data
representations = product_data["representations"]
label = f"{folder_path}_{product_name}_v{version:>03}"
families = ["csv_ingest"]
if slate_exists:
# adding slate to families mainly for loaders to be able
# to filter out slates
families.append("slate")
# make product data
product_data = {
"name": instance_name,
"folderPath": folder_path,
"families": families,
"label": label,
"task": task_name,
"variant": variant,
"source": "csv",
"frameStart": frame_start,
"frameEnd": frame_end,
"handleStart": handle_start,
"handleEnd": handle_end,
"fps": fps,
"version": version,
"comment": version_comment,
}
# create new instance
new_instance = CreatedInstance(
product_type, product_name, product_data, self
)
self._store_new_instance(new_instance)
if not new_instance.get("prepared_data_for_repres"):
new_instance["prepared_data_for_repres"] = []
base_thumbnail_repre_data = {
"name": "thumbnail",
"ext": None,
"files": None,
"stagingDir": None,
"stagingDir_persistent": True,
"tags": ["thumbnail", "delete"],
}
# need to populate all thumbnails for all representations
# so we can check if unique thumbnail per representation
# is needed
thumbnails = [
repre_data["thumbnailPath"]
for repre_data in representations.values()
if repre_data["thumbnailPath"]
]
multiple_thumbnails = len(set(thumbnails)) > 1
explicit_output_name = None
thumbnails_processed = False
for filepath, repre_data in representations.items():
# check if any review derivate tag is present
reviewable = any(
tag for tag in repre_data.get("tags", [])
# tag can be `ftrackreview` or `review`
if "review" in tag
)
# since we need to populate multiple thumbnails as
# representation with outputName for (Ftrack instance
# integrator) pairing with reviewable video representations
if (
thumbnails
and multiple_thumbnails
and reviewable
):
# multiple unique thumbnails per representation needs
# grouping by outputName
# mainly used in Ftrack instance integrator
explicit_output_name = repre_data["representationName"]
relative_thumbnail_path = repre_data["thumbnailPath"]
# representation might not have thumbnail path
# so ignore this one
if not relative_thumbnail_path:
continue
thumb_dir, thumb_file = \
self._get_refactor_thumbnail_path(
staging_dir, relative_thumbnail_path)
filename, ext = os.path.splitext(thumb_file)
thumbnail_repr_data = deepcopy(
base_thumbnail_repre_data)
thumbnail_repr_data.update({
"name": "thumbnail_{}".format(filename),
"ext": ext[1:],
"files": thumb_file,
"stagingDir": thumb_dir,
"outputName": explicit_output_name,
})
new_instance["prepared_data_for_repres"].append({
"type": "thumbnail",
"colorspace": None,
"representation": thumbnail_repr_data,
})
# also add thumbnailPath for ayon to integrate
if not new_instance.get("thumbnailPath"):
new_instance["thumbnailPath"] = (
os.path.join(thumb_dir, thumb_file)
)
elif (
thumbnails
and not multiple_thumbnails
and not thumbnails_processed
or not reviewable
):
"""
For case where we have only one thumbnail
and not reviewable medias. This needs to be processed
only once per instance.
"""
if not thumbnails:
continue
# here we will use only one thumbnail for
# all representations
relative_thumbnail_path = repre_data["thumbnailPath"]
# popping last thumbnail from list since it is only one
# and we do not need to iterate again over it
if not relative_thumbnail_path:
relative_thumbnail_path = thumbnails.pop()
thumb_dir, thumb_file = \
self._get_refactor_thumbnail_path(
staging_dir, relative_thumbnail_path)
_, ext = os.path.splitext(thumb_file)
thumbnail_repr_data = deepcopy(
base_thumbnail_repre_data)
thumbnail_repr_data.update({
"ext": ext[1:],
"files": thumb_file,
"stagingDir": thumb_dir
})
new_instance["prepared_data_for_repres"].append({
"type": "thumbnail",
"colorspace": None,
"representation": thumbnail_repr_data,
})
# also add thumbnailPath for ayon to integrate
if not new_instance.get("thumbnailPath"):
new_instance["thumbnailPath"] = (
os.path.join(thumb_dir, thumb_file)
)
thumbnails_processed = True
# get representation data
representation_data = self._get_representation_data(
filepath, repre_data, staging_dir,
explicit_output_name
)
new_instance["prepared_data_for_repres"].append({
"type": "media",
"colorspace": repre_data["colorspace"],
"representation": representation_data,
})
def _get_refactor_thumbnail_path(
self, staging_dir, relative_thumbnail_path):
thumbnail_abs_path = os.path.join(
staging_dir, relative_thumbnail_path)
return os.path.split(
thumbnail_abs_path)
def _get_representation_data(
self, filepath, repre_data, staging_dir, explicit_output_name=None
):
"""Get representation data
Args:
filepath (str): Filepath to representation file.
repre_data (dict): Representation data from CSV file.
staging_dir (str): Staging directory.
explicit_output_name (Optional[str]): Explicit output name.
For grouping purposes with reviewable components.
Defaults to None.
"""
# get extension of file
basename = os.path.basename(filepath)
extension = os.path.splitext(filepath)[-1].lower()
# validate filepath is having correct extension based on output
repre_name = repre_data["representationName"]
repre_config_data = None
for repre in self.representations_config["representations"]:
if repre["name"] == repre_name:
repre_config_data = repre
break
if not repre_config_data:
raise CreatorError(
f"Representation '{repre_name}' not found "
"in config representation data."
)
validate_extensions = repre_config_data["extensions"]
if extension not in validate_extensions:
raise CreatorError(
f"File extension '{extension}' not valid for "
f"output '{validate_extensions}'."
)
is_sequence = (extension in IMAGE_EXTENSIONS)
# convert ### string in file name to %03d
# this is for correct frame range validation
# example: file.###.exr -> file.%03d.exr
if "#" in basename:
padding = len(basename.split("#")) - 1
basename = basename.replace("#" * padding, f"%0{padding}d")
is_sequence = True
# make absolute path to file
absfilepath = os.path.normpath(os.path.join(staging_dir, filepath))
dirname = os.path.dirname(absfilepath)
# check if dirname exists
if not os.path.isdir(dirname):
raise CreatorError(
f"Directory '{dirname}' does not exist."
)
# collect all data from dirname
paths_for_collection = []
for file in os.listdir(dirname):
filepath = os.path.join(dirname, file)
paths_for_collection.append(filepath)
collections, _ = clique.assemble(paths_for_collection)
if collections:
collections = collections[0]
else:
if is_sequence:
raise CreatorError(
f"No collections found in directory '{dirname}'."
)
frame_start = None
frame_end = None
if is_sequence:
files = [os.path.basename(file) for file in collections]
frame_start = list(collections.indexes)[0]
frame_end = list(collections.indexes)[-1]
else:
files = basename
tags = deepcopy(repre_data["tags"])
# if slate in repre_data is True then remove one frame from start
if repre_data["slate"]:
tags.append("has_slate")
# get representation data
representation_data = {
"name": repre_name,
"ext": extension[1:],
"files": files,
"stagingDir": dirname,
"stagingDir_persistent": True,
"tags": tags,
}
if extension in VIDEO_EXTENSIONS:
representation_data.update({
"fps": repre_data["fps"],
"outputName": repre_name,
})
if explicit_output_name:
representation_data["outputName"] = explicit_output_name
if frame_start:
representation_data["frameStart"] = frame_start
if frame_end:
representation_data["frameEnd"] = frame_end
return representation_data
def _get_data_from_csv(
self, package_dir, filename
):
"""Generate instances from the csv file"""
# get current project name and code from context.data
project_name = self.create_context.get_current_project_name()
csv_file_path = os.path.join(
package_dir, filename
)
# make sure csv file contains columns from following list
required_columns = [
column["name"] for column in self.columns_config["columns"]
if column["required_column"]
]
# read csv file
with open(csv_file_path, "r") as csv_file:
csv_content = csv_file.read()
# read csv file with DictReader
csv_reader = csv.DictReader(
StringIO(csv_content),
delimiter=self.columns_config["csv_delimiter"]
)
# fix fieldnames
# sometimes someone can keep extra space at the start or end of
# the column name
all_columns = [
" ".join(column.rsplit()) for column in csv_reader.fieldnames]
# return back fixed fieldnames
csv_reader.fieldnames = all_columns
# check if csv file contains all required columns
if any(column not in all_columns for column in required_columns):
raise CreatorError(
f"Missing required columns: {required_columns}"
)
csv_data = {}
# get data from csv file
for row in csv_reader:
# Get required columns first
# TODO: will need to be folder path in CSV
# TODO: `context_asset_name` is now `folder_path`
folder_path = self._get_row_value_with_validation(
"Folder Path", row)
task_name = self._get_row_value_with_validation(
"Task Name", row)
version = self._get_row_value_with_validation(
"Version", row)
# Get optional columns
variant = self._get_row_value_with_validation(
"Variant", row)
product_type = self._get_row_value_with_validation(
"Product Type", row)
pre_product_name = (
f"{task_name}{variant}{product_type}"
f"{version}".replace(" ", "").lower()
)
# get representation data
filename, representation_data = \
self._get_representation_row_data(row)
# TODO: batch query of all folder paths and task names
# get folder entity from folder path
folder_entity = get_folder_by_path(
project_name, folder_path)
# make sure asset exists
if not folder_entity:
raise CreatorError(
f"Asset '{folder_path}' not found."
)
# first get all tasks on the folder entity and then find
task_entity = get_task_by_name(
project_name, folder_entity["id"], task_name)
# check if task name is valid task in asset doc
if not task_entity:
raise CreatorError(
f"Task '{task_name}' not found in asset doc."
)
# get all csv data into one dict and make sure there are no
# duplicates data are already validated and sorted under
# correct existing asset also check if asset exists and if
# task name is valid task in asset doc and representations
# are distributed under products following variants
if folder_path not in csv_data:
csv_data[folder_path] = {
"folder_entity": folder_entity,
"products": {
pre_product_name: {
"task_name": task_name,
"task_type": task_entity["taskType"],
"variant": variant,
"product_type": product_type,
"version": version,
"representations": {
filename: representation_data,
},
}
}
}
else:
csv_products = csv_data[folder_path]["products"]
if pre_product_name not in csv_products:
csv_products[pre_product_name] = {
"task_name": task_name,
"task_type": task_entity["taskType"],
"variant": variant,
"product_type": product_type,
"version": version,
"representations": {
filename: representation_data,
},
}
else:
csv_representations = \
csv_products[pre_product_name]["representations"]
if filename in csv_representations:
raise CreatorError(
f"Duplicate filename '{filename}' in csv file."
)
csv_representations[filename] = representation_data
return csv_data
def _get_representation_row_data(self, row_data):
"""Get representation row data"""
# Get required columns first
file_path = self._get_row_value_with_validation(
"File Path", row_data)
frame_start = self._get_row_value_with_validation(
"Frame Start", row_data)
frame_end = self._get_row_value_with_validation(
"Frame End", row_data)
handle_start = self._get_row_value_with_validation(
"Handle Start", row_data)
handle_end = self._get_row_value_with_validation(
"Handle End", row_data)
fps = self._get_row_value_with_validation(
"FPS", row_data)
# Get optional columns
thumbnail_path = self._get_row_value_with_validation(
"Version Thumbnail", row_data)
colorspace = self._get_row_value_with_validation(
"Representation Colorspace", row_data)
comment = self._get_row_value_with_validation(
"Version Comment", row_data)
repre = self._get_row_value_with_validation(
"Representation", row_data)
slate_exists = self._get_row_value_with_validation(
"Slate Exists", row_data)
repre_tags = self._get_row_value_with_validation(
"Representation Tags", row_data)
# convert tags value to list
tags_list = copy(self.representations_config["default_tags"])
if repre_tags:
tags_list = []
tags_delimiter = self.representations_config["tags_delimiter"]
# strip spaces from repre_tags
if tags_delimiter in repre_tags:
tags = repre_tags.split(tags_delimiter)
for _tag in tags:
tags_list.append(("".join(_tag.strip())).lower())
else:
tags_list.append(repre_tags)
representation_data = {
"colorspace": colorspace,
"comment": comment,
"representationName": repre,
"slate": slate_exists,
"tags": tags_list,
"thumbnailPath": thumbnail_path,
"frameStart": int(frame_start),
"frameEnd": int(frame_end),
"handleStart": int(handle_start),
"handleEnd": int(handle_end),
"fps": float(fps),
}
return file_path, representation_data
def _get_row_value_with_validation(
self, column_name, row_data, default_value=None
):
"""Get row value with validation"""
# get column data from column config
column_data = None
for column in self.columns_config["columns"]:
if column["name"] == column_name:
column_data = column
break
if not column_data:
raise CreatorError(
f"Column '{column_name}' not found in column config."
)
# get column value from row
column_value = row_data.get(column_name)
column_required = column_data["required_column"]
# check if column value is not empty string and column is required
if column_value == "" and column_required:
raise CreatorError(
f"Value in column '{column_name}' is required."
)
# get column type
column_type = column_data["type"]
# get column validation regex
column_validation = column_data["validation_pattern"]
# get column default value
column_default = default_value or column_data["default"]
if column_type in ["number", "decimal"] and column_default == 0:
column_default = None
# check if column value is not empty string
if column_value == "":
# set default value if column value is empty string
column_value = column_default
# set column value to correct type following column type
if column_type == "number" and column_value is not None:
column_value = int(column_value)
elif column_type == "decimal" and column_value is not None:
column_value = float(column_value)
elif column_type == "bool":
column_value = column_value in ["true", "True"]
# check if column value matches validation regex
if (
column_value is not None and
not re.match(str(column_validation), str(column_value))
):
raise CreatorError(
f"Column '{column_name}' value '{column_value}' "
f"does not match validation regex '{column_validation}' \n"
f"Row data: {row_data} \n"
f"Column data: {column_data}"
)
return column_value
def _pass_data_to_csv_instance(
self, instance_data, staging_dir, filename
):
"""Pass CSV representation file to instance data"""
representation = {
"name": "csv",
"ext": "csv",
"files": filename,
"stagingDir": staging_dir,
"stagingDir_persistent": True,
}
instance_data.update({
"label": f"CSV: {filename}",
"representations": [representation],
"stagingDir": staging_dir,
"stagingDir_persistent": True,
})
def get_instance_attr_defs(self):
return [
BoolDef(
"add_review_family",
default=True,
label="Review"
)
]
def get_pre_create_attr_defs(self):
"""Creating pre-create attributes at creator plugin.
Returns:
list: list of attribute object instances
"""
# Use same attributes as for instance attributes
attr_defs = [
FileDef(
"csv_filepath_data",
folders=False,
extensions=[".csv"],
allow_sequences=False,
single_item=True,
label="CSV File",
),
]
return attr_defs

View file

@ -0,0 +1,47 @@
from pprint import pformat
import pyblish.api
from ayon_core.pipeline import publish
class CollectCSVIngestInstancesData(
pyblish.api.InstancePlugin,
publish.AYONPyblishPluginMixin,
publish.ColormanagedPyblishPluginMixin
):
"""Collect CSV Ingest data from instance.
"""
label = "Collect CSV Ingest instances data"
order = pyblish.api.CollectorOrder + 0.1
hosts = ["traypublisher"]
families = ["csv_ingest"]
def process(self, instance):
# expecting [(colorspace, repre_data), ...]
prepared_repres_data_items = instance.data[
"prepared_data_for_repres"]
for prep_repre_data in prepared_repres_data_items:
type = prep_repre_data["type"]
colorspace = prep_repre_data["colorspace"]
repre_data = prep_repre_data["representation"]
# thumbnails should be skipped
if type == "media":
# colorspace name is passed from CSV column
self.set_representation_colorspace(
repre_data, instance.context, colorspace
)
elif type == "media" and colorspace is None:
# TODO: implement colorspace file rules file parsing
self.log.warning(
"Colorspace is not defined in csv for following"
f" representation: {pformat(repre_data)}"
)
pass
elif type == "thumbnail":
# thumbnails should be skipped
pass
instance.data["representations"].append(repre_data)

View file

@ -0,0 +1,31 @@
import pyblish.api
from ayon_core.pipeline import publish
class ExtractCSVFile(publish.Extractor):
"""
Extractor export CSV file
"""
label = "Extract CSV file"
order = pyblish.api.ExtractorOrder - 0.45
families = ["csv_ingest_file"]
hosts = ["traypublisher"]
def process(self, instance):
csv_file_data = instance.data["csvFileData"]
representation_csv = {
'name': "csv_data",
'ext': "csv",
'files': csv_file_data["filename"],
"stagingDir": csv_file_data["staging_dir"],
"stagingDir_persistent": True
}
instance.data["representations"].append(representation_csv)
self.log.info("Added CSV file representation: {}".format(
representation_csv))

View file

@ -16,6 +16,7 @@ class ValidateExistingVersion(
order = ValidateContentsOrder
hosts = ["traypublisher"]
targets = ["local"]
actions = [RepairAction]

View file

@ -16,6 +16,8 @@ class ValidateFrameRange(OptionalPyblishPluginMixin,
label = "Validate Frame Range"
hosts = ["traypublisher"]
families = ["render", "plate"]
targets = ["local"]
order = ValidateContentsOrder
optional = True

View file

@ -260,11 +260,11 @@ class UEProjectGenerationWorker(UEWorker):
self.failed.emit(msg, return_code)
raise RuntimeError(msg)
# ensure we have PySide2 installed in engine
# ensure we have PySide2/6 installed in engine
self.progress.emit(0)
self.stage_begin.emit(
(f"Checking PySide2 installation... {stage_count} "
(f"Checking Qt bindings installation... {stage_count} "
f" out of {stage_count}"))
python_path = None
if platform.system().lower() == "windows":
@ -287,11 +287,30 @@ class UEProjectGenerationWorker(UEWorker):
msg = f"Unreal Python not found at {python_path}"
self.failed.emit(msg, 1)
raise RuntimeError(msg)
pyside_cmd = [python_path.as_posix(),
"-m",
"pip",
"install",
"pyside2"]
pyside_version = "PySide2"
ue_version = self.ue_version.split(".")
if int(ue_version[0]) == 5 and int(ue_version[1]) >= 4:
# Use PySide6 6.6.3 because 6.7.0 had a bug
# - 'QPushButton' can't be added to 'QBoxLayout'
pyside_version = "PySide6==6.6.3"
site_packages_prefix = python_path.parent.as_posix()
pyside_cmd = [
python_path.as_posix(),
"-m", "pip",
"install",
"--ignore-installed",
pyside_version,
]
if platform.system().lower() == "windows":
pyside_cmd += ["--target", site_packages_prefix]
print(f"--- Installing {pyside_version} ...")
print(" ".join(pyside_cmd))
pyside_install = subprocess.Popen(pyside_cmd,
stdout=subprocess.PIPE,
@ -306,8 +325,8 @@ class UEProjectGenerationWorker(UEWorker):
return_code = pyside_install.wait()
if return_code and return_code != 0:
msg = ("Failed to create the project! "
"The installation of PySide2 has failed!")
msg = (f"Failed to create the project! {return_code} "
f"The installation of {pyside_version} has failed!: {pyside_install}")
self.failed.emit(msg, return_code)
raise RuntimeError(msg)

View file

@ -27,6 +27,10 @@ from .local_settings import (
get_openpype_username,
)
from .ayon_connection import initialize_ayon_connection
from .cache import (
CacheItem,
NestedCacheItem,
)
from .events import (
emit_event,
register_event_callback
@ -157,6 +161,9 @@ __all__ = [
"initialize_ayon_connection",
"CacheItem",
"NestedCacheItem",
"emit_event",
"register_event_callback",

View file

@ -0,0 +1,250 @@
import time
import collections
InitInfo = collections.namedtuple(
"InitInfo",
["default_factory", "lifetime"]
)
def _default_factory_func():
return None
class CacheItem:
"""Simple cache item with lifetime and default factory for default value.
Default factory should return default value that is used on init
and on reset.
Args:
default_factory (Optional[callable]): Function that returns default
value used on init and on reset.
lifetime (Optional[int]): Lifetime of the cache data in seconds.
Default lifetime is 120 seconds.
"""
def __init__(self, default_factory=None, lifetime=None):
if lifetime is None:
lifetime = 120
self._lifetime = lifetime
self._last_update = None
if default_factory is None:
default_factory = _default_factory_func
self._default_factory = default_factory
self._data = default_factory()
@property
def is_valid(self):
"""Is cache valid to use.
Return:
bool: True if cache is valid, False otherwise.
"""
if self._last_update is None:
return False
return (time.time() - self._last_update) < self._lifetime
def set_lifetime(self, lifetime):
"""Change lifetime of cache item.
Args:
lifetime (int): Lifetime of the cache data in seconds.
"""
self._lifetime = lifetime
def set_invalid(self):
"""Set cache as invalid."""
self._last_update = None
def reset(self):
"""Set cache as invalid and reset data."""
self._last_update = None
self._data = self._default_factory()
def get_data(self):
"""Receive cached data.
Returns:
Any: Any data that are cached.
"""
return self._data
def update_data(self, data):
"""Update cache data.
Args:
data (Any): Any data that are cached.
"""
self._data = data
self._last_update = time.time()
class NestedCacheItem:
"""Helper for cached items stored in nested structure.
Example:
>>> cache = NestedCacheItem(levels=2, default_factory=lambda: 0)
>>> cache["a"]["b"].is_valid
False
>>> cache["a"]["b"].get_data()
0
>>> cache["a"]["b"] = 1
>>> cache["a"]["b"].is_valid
True
>>> cache["a"]["b"].get_data()
1
>>> cache.reset()
>>> cache["a"]["b"].is_valid
False
Args:
levels (int): Number of nested levels where read cache is stored.
default_factory (Optional[callable]): Function that returns default
value used on init and on reset.
lifetime (Optional[int]): Lifetime of the cache data in seconds.
Default value is based on default value of 'CacheItem'.
_init_info (Optional[InitInfo]): Private argument. Init info for
nested cache where created from parent item.
"""
def __init__(
self, levels=1, default_factory=None, lifetime=None, _init_info=None
):
if levels < 1:
raise ValueError("Nested levels must be greater than 0")
self._data_by_key = {}
if _init_info is None:
_init_info = InitInfo(default_factory, lifetime)
self._init_info = _init_info
self._levels = levels
def __getitem__(self, key):
"""Get cached data.
Args:
key (str): Key of the cache item.
Returns:
Union[NestedCacheItem, CacheItem]: Cache item.
"""
cache = self._data_by_key.get(key)
if cache is None:
if self._levels > 1:
cache = NestedCacheItem(
levels=self._levels - 1,
_init_info=self._init_info
)
else:
cache = CacheItem(
self._init_info.default_factory,
self._init_info.lifetime
)
self._data_by_key[key] = cache
return cache
def __setitem__(self, key, value):
"""Update cached data.
Args:
key (str): Key of the cache item.
value (Any): Any data that are cached.
"""
if self._levels > 1:
raise AttributeError((
"{} does not support '__setitem__'. Lower nested level by {}"
).format(self.__class__.__name__, self._levels - 1))
cache = self[key]
cache.update_data(value)
def get(self, key):
"""Get cached data.
Args:
key (str): Key of the cache item.
Returns:
Union[NestedCacheItem, CacheItem]: Cache item.
"""
return self[key]
def cached_count(self):
"""Amount of cached items.
Returns:
int: Amount of cached items.
"""
return len(self._data_by_key)
def clear_key(self, key):
"""Clear cached item by key.
Args:
key (str): Key of the cache item.
"""
self._data_by_key.pop(key, None)
def clear_invalid(self):
"""Clear all invalid cache items.
Note:
To clear all cache items use 'reset'.
"""
changed = {}
children_are_nested = self._levels > 1
for key, cache in tuple(self._data_by_key.items()):
if children_are_nested:
output = cache.clear_invalid()
if output:
changed[key] = output
if not cache.cached_count():
self._data_by_key.pop(key)
elif not cache.is_valid:
changed[key] = cache.get_data()
self._data_by_key.pop(key)
return changed
def reset(self):
"""Reset cache.
Note:
To clear only invalid cache items use 'clear_invalid'.
"""
self._data_by_key = {}
def set_lifetime(self, lifetime):
"""Change lifetime of all children cache items.
Args:
lifetime (int): Lifetime of the cache data in seconds.
"""
self._init_info.lifetime = lifetime
for cache in self._data_by_key.values():
cache.set_lifetime(lifetime)
@property
def is_valid(self):
"""Raise reasonable error when called on wrong level.
Raises:
AttributeError: If called on nested cache item.
"""
raise AttributeError((
"{} does not support 'is_valid'. Lower nested level by '{}'"
).format(self.__class__.__name__, self._levels))

View file

@ -97,6 +97,15 @@ from .context_tools import (
get_current_folder_path,
get_current_task_name
)
from .workfile import (
discover_workfile_build_plugins,
register_workfile_build_plugin,
deregister_workfile_build_plugin,
register_workfile_build_plugin_path,
deregister_workfile_build_plugin_path,
)
install = install_host
uninstall = uninstall_host
@ -198,6 +207,13 @@ __all__ = (
"get_current_folder_path",
"get_current_task_name",
# Workfile templates
"discover_workfile_build_plugins",
"register_workfile_build_plugin",
"deregister_workfile_build_plugin",
"register_workfile_build_plugin_path",
"deregister_workfile_build_plugin_path",
# Backwards compatible function names
"install",
"uninstall",

View file

@ -3,11 +3,16 @@ import re
import copy
import platform
import collections
import time
import ayon_api
from ayon_core.lib import Logger, get_local_site_id, StringTemplate
from ayon_core.lib import (
Logger,
get_local_site_id,
StringTemplate,
CacheItem,
NestedCacheItem,
)
from ayon_core.addon import AddonsManager
from .exceptions import RootCombinationError, ProjectNotSet
@ -397,62 +402,11 @@ class BaseAnatomy(object):
)
class CacheItem:
"""Helper to cache data.
Helper does not handle refresh of data and does not mark data as outdated.
Who uses the object should check of outdated state on his own will.
"""
default_lifetime = 10
def __init__(self, lifetime=None):
self._data = None
self._cached = None
self._lifetime = lifetime or self.default_lifetime
@property
def data(self):
"""Cached data/object.
Returns:
Any: Whatever was cached.
"""
return self._data
@property
def is_outdated(self):
"""Item has outdated cache.
Lifetime of cache item expired or was not yet set.
Returns:
bool: Item is outdated.
"""
if self._cached is None:
return True
return (time.time() - self._cached) > self._lifetime
def update_data(self, data):
"""Update cache of data.
Args:
data (Any): Data to cache.
"""
self._data = data
self._cached = time.time()
class Anatomy(BaseAnatomy):
_sitesync_addon_cache = CacheItem()
_project_cache = collections.defaultdict(CacheItem)
_default_site_id_cache = collections.defaultdict(CacheItem)
_root_overrides_cache = collections.defaultdict(
lambda: collections.defaultdict(CacheItem)
)
_project_cache = NestedCacheItem(lifetime=10)
_sitesync_addon_cache = CacheItem(lifetime=60)
_default_site_id_cache = NestedCacheItem(lifetime=60)
_root_overrides_cache = NestedCacheItem(2, lifetime=60)
def __init__(
self, project_name=None, site_name=None, project_entity=None
@ -477,18 +431,18 @@ class Anatomy(BaseAnatomy):
@classmethod
def get_project_entity_from_cache(cls, project_name):
project_cache = cls._project_cache[project_name]
if project_cache.is_outdated:
if not project_cache.is_valid:
project_cache.update_data(ayon_api.get_project(project_name))
return copy.deepcopy(project_cache.data)
return copy.deepcopy(project_cache.get_data())
@classmethod
def get_sitesync_addon(cls):
if cls._sitesync_addon_cache.is_outdated:
if not cls._sitesync_addon_cache.is_valid:
manager = AddonsManager()
cls._sitesync_addon_cache.update_data(
manager.get_enabled_addon("sitesync")
)
return cls._sitesync_addon_cache.data
return cls._sitesync_addon_cache.get_data()
@classmethod
def _get_studio_roots_overrides(cls, project_name):
@ -533,14 +487,14 @@ class Anatomy(BaseAnatomy):
elif not site_name:
# Use sync server to receive active site name
project_cache = cls._default_site_id_cache[project_name]
if project_cache.is_outdated:
if not project_cache.is_valid:
project_cache.update_data(
sitesync_addon.get_active_site_type(project_name)
)
site_name = project_cache.data
site_name = project_cache.get_data()
site_cache = cls._root_overrides_cache[project_name][site_name]
if site_cache.is_outdated:
if not site_cache.is_valid:
if site_name == "studio":
# Handle studio root overrides without sync server
# - studio root overrides can be done even without sync server
@ -553,4 +507,4 @@ class Anatomy(BaseAnatomy):
project_name, site_name
)
site_cache.update_data(roots_overrides)
return site_cache.data
return site_cache.get_data()

View file

@ -1790,10 +1790,10 @@ class CreateContext:
creator_identifier = creator_class.identifier
if creator_identifier in creators:
self.log.warning((
"Duplicated Creator identifier. "
"Using first and skipping following"
))
self.log.warning(
"Duplicate Creator identifier: '%s'. Using first Creator "
"and skipping: %s", creator_identifier, creator_class
)
continue
# Filter by host name

View file

@ -225,6 +225,7 @@ def create_skeleton_instance(
instance_skeleton_data = {
"productType": product_type,
"productName": data["productName"],
"task": data["task"],
"families": families,
"folderPath": data["folderPath"],
"frameStart": time_data.start,

View file

@ -21,6 +21,15 @@ from .utils import (
from .build_workfile import BuildWorkfile
from .workfile_template_builder import (
discover_workfile_build_plugins,
register_workfile_build_plugin,
deregister_workfile_build_plugin,
register_workfile_build_plugin_path,
deregister_workfile_build_plugin_path,
)
__all__ = (
"get_workfile_template_key_from_context",
"get_workfile_template_key",
@ -39,4 +48,10 @@ __all__ = (
"should_open_workfiles_tool_on_launch",
"BuildWorkfile",
"discover_workfile_build_plugins",
"register_workfile_build_plugin",
"deregister_workfile_build_plugin",
"register_workfile_build_plugin_path",
"deregister_workfile_build_plugin_path",
)

View file

@ -36,6 +36,7 @@ from ayon_core.lib import (
filter_profiles,
attribute_definitions,
)
from ayon_core.lib.events import EventSystem, EventCallback, Event
from ayon_core.lib.attribute_definitions import get_attributes_keys
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.load import (
@ -43,6 +44,13 @@ from ayon_core.pipeline.load import (
get_representation_contexts,
load_with_repre_context,
)
from ayon_core.pipeline.plugin_discover import (
discover,
register_plugin,
register_plugin_path,
deregister_plugin,
deregister_plugin_path
)
from ayon_core.pipeline.create import (
discover_legacy_creator_plugins,
@ -124,6 +132,8 @@ class AbstractTemplateBuilder(object):
self._current_task_entity = _NOT_SET
self._linked_folder_entities = _NOT_SET
self._event_system = EventSystem()
@property
def project_name(self):
if isinstance(self._host, HostBase):
@ -211,10 +221,14 @@ class AbstractTemplateBuilder(object):
Returns:
List[PlaceholderPlugin]: Plugin classes available for host.
"""
plugins = []
# Backwards compatibility
if hasattr(self._host, "get_workfile_build_placeholder_plugins"):
return self._host.get_workfile_build_placeholder_plugins()
return []
plugins.extend(discover(PlaceholderPlugin))
return plugins
@property
def host(self):
@ -257,6 +271,8 @@ class AbstractTemplateBuilder(object):
self._project_settings = None
self._event_system = EventSystem()
self.clear_shared_data()
self.clear_shared_populate_data()
@ -329,7 +345,7 @@ class AbstractTemplateBuilder(object):
is good practice to check if the same value is not already stored under
different key or if the key is not already used for something else.
Key should be self explanatory to content.
Key should be self-explanatory to content.
- wrong: 'folder'
- good: 'folder_name'
@ -375,7 +391,7 @@ class AbstractTemplateBuilder(object):
is good practice to check if the same value is not already stored under
different key or if the key is not already used for something else.
Key should be self explanatory to content.
Key should be self-explanatory to content.
- wrong: 'folder'
- good: 'folder_path'
@ -395,7 +411,7 @@ class AbstractTemplateBuilder(object):
is good practice to check if the same value is not already stored under
different key or if the key is not already used for something else.
Key should be self explanatory to content.
Key should be self-explanatory to content.
- wrong: 'folder'
- good: 'folder_path'
@ -466,7 +482,7 @@ class AbstractTemplateBuilder(object):
return list(sorted(
placeholders,
key=lambda i: i.order
key=lambda placeholder: placeholder.order
))
def build_template(
@ -498,15 +514,21 @@ class AbstractTemplateBuilder(object):
process if version is created
"""
template_preset = self.get_template_preset()
if template_path is None:
template_path = template_preset["path"]
if keep_placeholders is None:
keep_placeholders = template_preset["keep_placeholder"]
if create_first_version is None:
create_first_version = template_preset["create_first_version"]
if any(
value is None
for value in [
template_path,
keep_placeholders,
create_first_version,
]
):
template_preset = self.get_template_preset()
if template_path is None:
template_path = template_preset["path"]
if keep_placeholders is None:
keep_placeholders = template_preset["keep_placeholder"]
if create_first_version is None:
create_first_version = template_preset["create_first_version"]
# check if first version is created
created_version_workfile = False
@ -685,7 +707,7 @@ class AbstractTemplateBuilder(object):
for placeholder in placeholders
}
all_processed = len(placeholders) == 0
# Counter is checked at the ned of a loop so the loop happens at least
# Counter is checked at the end of a loop so the loop happens at least
# once.
iter_counter = 0
while not all_processed:
@ -729,6 +751,16 @@ class AbstractTemplateBuilder(object):
placeholder.set_finished()
# Trigger on_depth_processed event
self.emit_event(
topic="template.depth_processed",
data={
"depth": iter_counter,
"placeholders_by_scene_id": placeholder_by_scene_id
},
source="builder"
)
# Clear shared data before getting new placeholders
self.clear_shared_populate_data()
@ -747,6 +779,16 @@ class AbstractTemplateBuilder(object):
placeholder_by_scene_id[identifier] = placeholder
placeholders.append(placeholder)
# Trigger on_finished event
self.emit_event(
topic="template.finished",
data={
"depth": iter_counter,
"placeholders_by_scene_id": placeholder_by_scene_id,
},
source="builder"
)
self.refresh()
def _get_build_profiles(self):
@ -772,12 +814,14 @@ class AbstractTemplateBuilder(object):
- 'project_settings/{host name}/templated_workfile_build/profiles'
Returns:
str: Path to a template file with placeholders.
dict: Dictionary with `path`, `keep_placeholder` and
`create_first_version` settings from the template preset
for current context.
Raises:
TemplateProfileNotFound: When profiles are not filled.
TemplateLoadFailed: Profile was found but path is not set.
TemplateNotFound: Path was set but file does not exists.
TemplateNotFound: Path was set but file does not exist.
"""
host_name = self.host_name
@ -872,6 +916,30 @@ class AbstractTemplateBuilder(object):
"create_first_version": create_first_version
}
def emit_event(self, topic, data=None, source=None) -> Event:
return self._event_system.emit(topic, data, source)
def add_event_callback(self, topic, callback, order=None):
return self._event_system.add_callback(topic, callback, order=order)
def add_on_finished_callback(
self, callback, order=None
) -> EventCallback:
return self.add_event_callback(
topic="template.finished",
callback=callback,
order=order
)
def add_on_depth_processed_callback(
self, callback, order=None
) -> EventCallback:
return self.add_event_callback(
topic="template.depth_processed",
callback=callback,
order=order
)
@six.add_metaclass(ABCMeta)
class PlaceholderPlugin(object):
@ -1045,7 +1113,7 @@ class PlaceholderPlugin(object):
Using shared data from builder but stored under plugin identifier.
Key should be self explanatory to content.
Key should be self-explanatory to content.
- wrong: 'folder'
- good: 'folder_path'
@ -1085,7 +1153,7 @@ class PlaceholderPlugin(object):
Using shared data from builder but stored under plugin identifier.
Key should be self explanatory to content.
Key should be self-explanatory to content.
- wrong: 'folder'
- good: 'folder_path'
@ -1107,10 +1175,10 @@ class PlaceholderItem(object):
"""Item representing single item in scene that is a placeholder to process.
Items are always created and updated by their plugins. Each plugin can use
modified class of 'PlacehoderItem' but only to add more options instead of
modified class of 'PlaceholderItem' but only to add more options instead of
new other.
Scene identifier is used to avoid processing of the palceholder item
Scene identifier is used to avoid processing of the placeholder item
multiple times so must be unique across whole workfile builder.
Args:
@ -1162,7 +1230,7 @@ class PlaceholderItem(object):
"""Placeholder data which can modify how placeholder is processed.
Possible general keys
- order: Can define the order in which is palceholder processed.
- order: Can define the order in which is placeholder processed.
Lower == earlier.
Other keys are defined by placeholder and should validate them on item
@ -1264,11 +1332,9 @@ class PlaceholderLoadMixin(object):
"""Unified attribute definitions for load placeholder.
Common function for placeholder plugins used for loading of
repsentations. Use it in 'get_placeholder_options'.
representations. Use it in 'get_placeholder_options'.
Args:
plugin (PlaceholderPlugin): Plugin used for loading of
representations.
options (Dict[str, Any]): Already available options which are used
as defaults for attributes.
@ -1468,7 +1534,9 @@ class PlaceholderLoadMixin(object):
product_name_regex = None
if product_name_regex_value:
product_name_regex = re.compile(product_name_regex_value)
product_type = placeholder.data["family"]
product_type = placeholder.data.get("product_type")
if product_type is None:
product_type = placeholder.data["family"]
builder_type = placeholder.data["builder_type"]
folder_ids = []
@ -1529,35 +1597,22 @@ class PlaceholderLoadMixin(object):
pass
def _reduce_last_version_repre_entities(self, representations):
"""Reduce representations to last verison."""
def _reduce_last_version_repre_entities(self, repre_contexts):
"""Reduce representations to last version."""
mapping = {}
# TODO use representation context with entities
# - using 'folder', 'subset' and 'version' from context on
# representation is danger
for repre_entity in representations:
repre_context = repre_entity["context"]
folder_name = repre_context["asset"]
product_name = repre_context["subset"]
version = repre_context.get("version", -1)
if folder_name not in mapping:
mapping[folder_name] = {}
product_mapping = mapping[folder_name]
if product_name not in product_mapping:
product_mapping[product_name] = collections.defaultdict(list)
version_mapping = product_mapping[product_name]
version_mapping[version].append(repre_entity)
version_mapping_by_product_id = {}
for repre_context in repre_contexts:
product_id = repre_context["product"]["id"]
version = repre_context["version"]["version"]
version_mapping = version_mapping_by_product_id.setdefault(
product_id, {}
)
version_mapping.setdefault(version, []).append(repre_context)
output = []
for product_mapping in mapping.values():
for version_mapping in product_mapping.values():
last_version = tuple(sorted(version_mapping.keys()))[-1]
output.extend(version_mapping[last_version])
for version_mapping in version_mapping_by_product_id.values():
last_version = max(version_mapping.keys())
output.extend(version_mapping[last_version])
return output
def populate_load_placeholder(self, placeholder, ignore_repre_ids=None):
@ -1585,32 +1640,33 @@ class PlaceholderLoadMixin(object):
loader_name = placeholder.data["loader"]
loader_args = self.parse_loader_args(placeholder.data["loader_args"])
placeholder_representations = self._get_representations(placeholder)
placeholder_representations = [
repre_entity
for repre_entity in self._get_representations(placeholder)
if repre_entity["id"] not in ignore_repre_ids
]
filtered_representations = []
for representation in self._reduce_last_version_repre_entities(
placeholder_representations
):
repre_id = representation["id"]
if repre_id not in ignore_repre_ids:
filtered_representations.append(representation)
if not filtered_representations:
repre_load_contexts = get_representation_contexts(
self.project_name, placeholder_representations
)
filtered_repre_contexts = self._reduce_last_version_repre_entities(
repre_load_contexts.values()
)
if not filtered_repre_contexts:
self.log.info((
"There's no representation for this placeholder: {}"
).format(placeholder.scene_identifier))
if not placeholder.data.get("keep_placeholder", True):
self.delete_placeholder(placeholder)
return
repre_load_contexts = get_representation_contexts(
self.project_name, filtered_representations
)
loaders_by_name = self.builder.get_loaders_by_name()
self._before_placeholder_load(
placeholder
)
failed = False
for repre_load_context in repre_load_contexts.values():
for repre_load_context in filtered_repre_contexts:
folder_path = repre_load_context["folder"]["path"]
product_name = repre_load_context["product"]["name"]
representation = repre_load_context["representation"]
@ -1695,8 +1751,6 @@ class PlaceholderCreateMixin(object):
publishable instances. Use it with 'get_placeholder_options'.
Args:
plugin (PlaceholderPlugin): Plugin used for creating of
publish instances.
options (Dict[str, Any]): Already available options which are used
as defaults for attributes.
@ -1918,3 +1972,23 @@ class CreatePlaceholderItem(PlaceholderItem):
def create_failed(self, creator_data):
self._failed_created_publish_instances.append(creator_data)
def discover_workfile_build_plugins(*args, **kwargs):
return discover(PlaceholderPlugin, *args, **kwargs)
def register_workfile_build_plugin(plugin: PlaceholderPlugin):
register_plugin(PlaceholderPlugin, plugin)
def deregister_workfile_build_plugin(plugin: PlaceholderPlugin):
deregister_plugin(PlaceholderPlugin, plugin)
def register_workfile_build_plugin_path(path: str):
register_plugin_path(PlaceholderPlugin, path)
def deregister_workfile_build_plugin_path(path: str):
deregister_plugin_path(PlaceholderPlugin, path)

View file

@ -1,501 +1,426 @@
# TODO This plugin is not converted for AYON
#
# import collections
# import os
# import uuid
#
# import clique
# import ayon_api
# from pymongo import UpdateOne
# import qargparse
# from qtpy import QtWidgets, QtCore
#
# from ayon_core import style
# from ayon_core.addon import AddonsManager
# from ayon_core.lib import format_file_size
# from ayon_core.pipeline import load, Anatomy
# from ayon_core.pipeline.load import (
# get_representation_path_with_anatomy,
# InvalidRepresentationContext,
# )
#
#
# class DeleteOldVersions(load.ProductLoaderPlugin):
# """Deletes specific number of old version"""
#
# is_multiple_contexts_compatible = True
# sequence_splitter = "__sequence_splitter__"
#
# representations = {"*"}
# product_types = {"*"}
# tool_names = ["library_loader"]
#
# label = "Delete Old Versions"
# order = 35
# icon = "trash"
# color = "#d8d8d8"
#
# options = [
# qargparse.Integer(
# "versions_to_keep", default=2, min=0, help="Versions to keep:"
# ),
# qargparse.Boolean(
# "remove_publish_folder", help="Remove publish folder:"
# )
# ]
#
# def delete_whole_dir_paths(self, dir_paths, delete=True):
# size = 0
#
# for dir_path in dir_paths:
# # Delete all files and fodlers in dir path
# for root, dirs, files in os.walk(dir_path, topdown=False):
# for name in files:
# file_path = os.path.join(root, name)
# size += os.path.getsize(file_path)
# if delete:
# os.remove(file_path)
# self.log.debug("Removed file: {}".format(file_path))
#
# for name in dirs:
# if delete:
# os.rmdir(os.path.join(root, name))
#
# if not delete:
# continue
#
# # Delete even the folder and it's parents folders if they are empty
# while True:
# if not os.path.exists(dir_path):
# dir_path = os.path.dirname(dir_path)
# continue
#
# if len(os.listdir(dir_path)) != 0:
# break
#
# os.rmdir(os.path.join(dir_path))
#
# return size
#
# def path_from_representation(self, representation, anatomy):
# try:
# context = representation["context"]
# except KeyError:
# return (None, None)
#
# try:
# path = get_representation_path_with_anatomy(
# representation, anatomy
# )
# except InvalidRepresentationContext:
# return (None, None)
#
# sequence_path = None
# if "frame" in context:
# context["frame"] = self.sequence_splitter
# sequence_path = get_representation_path_with_anatomy(
# representation, anatomy
# )
#
# if sequence_path:
# sequence_path = sequence_path.normalized()
#
# return (path.normalized(), sequence_path)
#
# def delete_only_repre_files(self, dir_paths, file_paths, delete=True):
# size = 0
#
# for dir_id, dir_path in dir_paths.items():
# dir_files = os.listdir(dir_path)
# collections, remainders = clique.assemble(dir_files)
# for file_path, seq_path in file_paths[dir_id]:
# file_path_base = os.path.split(file_path)[1]
# # Just remove file if `frame` key was not in context or
# # filled path is in remainders (single file sequence)
# if not seq_path or file_path_base in remainders:
# if not os.path.exists(file_path):
# self.log.debug(
# "File was not found: {}".format(file_path)
# )
# continue
#
# size += os.path.getsize(file_path)
#
# if delete:
# os.remove(file_path)
# self.log.debug("Removed file: {}".format(file_path))
#
# if file_path_base in remainders:
# remainders.remove(file_path_base)
# continue
#
# seq_path_base = os.path.split(seq_path)[1]
# head, tail = seq_path_base.split(self.sequence_splitter)
#
# final_col = None
# for collection in collections:
# if head != collection.head or tail != collection.tail:
# continue
# final_col = collection
# break
#
# if final_col is not None:
# # Fill full path to head
# final_col.head = os.path.join(dir_path, final_col.head)
# for _file_path in final_col:
# if os.path.exists(_file_path):
#
# size += os.path.getsize(_file_path)
#
# if delete:
# os.remove(_file_path)
# self.log.debug(
# "Removed file: {}".format(_file_path)
# )
#
# _seq_path = final_col.format("{head}{padding}{tail}")
# self.log.debug("Removed files: {}".format(_seq_path))
# collections.remove(final_col)
#
# elif os.path.exists(file_path):
# size += os.path.getsize(file_path)
#
# if delete:
# os.remove(file_path)
# self.log.debug("Removed file: {}".format(file_path))
# else:
# self.log.debug(
# "File was not found: {}".format(file_path)
# )
#
# # Delete as much as possible parent folders
# if not delete:
# return size
#
# for dir_path in dir_paths.values():
# while True:
# if not os.path.exists(dir_path):
# dir_path = os.path.dirname(dir_path)
# continue
#
# if len(os.listdir(dir_path)) != 0:
# break
#
# self.log.debug("Removed folder: {}".format(dir_path))
# os.rmdir(dir_path)
#
# return size
#
# def message(self, text):
# msgBox = QtWidgets.QMessageBox()
# msgBox.setText(text)
# msgBox.setStyleSheet(style.load_stylesheet())
# msgBox.setWindowFlags(
# msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint
# )
# msgBox.exec_()
#
# def get_data(self, context, versions_count):
# product_entity = context["product"]
# folder_entity = context["folder"]
# project_name = context["project"]["name"]
# anatomy = Anatomy(project_name)
#
# versions = list(ayon_api.get_versions(
# project_name, product_ids=[product_entity["id"]]
# ))
#
# versions_by_parent = collections.defaultdict(list)
# for ent in versions:
# versions_by_parent[ent["productId"]].append(ent)
#
# def sort_func(ent):
# return int(ent["version"])
#
# all_last_versions = []
# for _parent_id, _versions in versions_by_parent.items():
# for idx, version in enumerate(
# sorted(_versions, key=sort_func, reverse=True)
# ):
# if idx >= versions_count:
# break
# all_last_versions.append(version)
#
# self.log.debug("Collected versions ({})".format(len(versions)))
#
# # Filter latest versions
# for version in all_last_versions:
# versions.remove(version)
#
# # Update versions_by_parent without filtered versions
# versions_by_parent = collections.defaultdict(list)
# for ent in versions:
# versions_by_parent[ent["productId"]].append(ent)
#
# # Filter already deleted versions
# versions_to_pop = []
# for version in versions:
# version_tags = version["data"].get("tags")
# if version_tags and "deleted" in version_tags:
# versions_to_pop.append(version)
#
# for version in versions_to_pop:
# msg = "Folder: \"{}\" | Product: \"{}\" | Version: \"{}\"".format(
# folder_entity["path"],
# product_entity["name"],
# version["version"]
# )
# self.log.debug((
# "Skipping version. Already tagged as `deleted`. < {} >"
# ).format(msg))
# versions.remove(version)
#
# version_ids = [ent["id"] for ent in versions]
#
# self.log.debug(
# "Filtered versions to delete ({})".format(len(version_ids))
# )
#
# if not version_ids:
# msg = "Skipping processing. Nothing to delete on {}/{}".format(
# folder_entity["path"], product_entity["name"]
# )
# self.log.info(msg)
# print(msg)
# return
#
# repres = list(ayon_api.get_representations(
# project_name, version_ids=version_ids
# ))
#
# self.log.debug(
# "Collected representations to remove ({})".format(len(repres))
# )
#
# dir_paths = {}
# file_paths_by_dir = collections.defaultdict(list)
# for repre in repres:
# file_path, seq_path = self.path_from_representation(
# repre, anatomy
# )
# if file_path is None:
# self.log.debug((
# "Could not format path for represenation \"{}\""
# ).format(str(repre)))
# continue
#
# dir_path = os.path.dirname(file_path)
# dir_id = None
# for _dir_id, _dir_path in dir_paths.items():
# if _dir_path == dir_path:
# dir_id = _dir_id
# break
#
# if dir_id is None:
# dir_id = uuid.uuid4()
# dir_paths[dir_id] = dir_path
#
# file_paths_by_dir[dir_id].append([file_path, seq_path])
#
# dir_ids_to_pop = []
# for dir_id, dir_path in dir_paths.items():
# if os.path.exists(dir_path):
# continue
#
# dir_ids_to_pop.append(dir_id)
#
# # Pop dirs from both dictionaries
# for dir_id in dir_ids_to_pop:
# dir_paths.pop(dir_id)
# paths = file_paths_by_dir.pop(dir_id)
# # TODO report of missing directories?
# paths_msg = ", ".join([
# "'{}'".format(path[0].replace("\\", "/")) for path in paths
# ])
# self.log.debug((
# "Folder does not exist. Deleting it's files skipped: {}"
# ).format(paths_msg))
#
# return {
# "dir_paths": dir_paths,
# "file_paths_by_dir": file_paths_by_dir,
# "versions": versions,
# "folder": folder_entity,
# "product": product_entity,
# "archive_product": versions_count == 0
# }
#
# def main(self, project_name, data, remove_publish_folder):
# # Size of files.
# size = 0
# if not data:
# return size
#
# if remove_publish_folder:
# size = self.delete_whole_dir_paths(data["dir_paths"].values())
# else:
# size = self.delete_only_repre_files(
# data["dir_paths"], data["file_paths_by_dir"]
# )
#
# mongo_changes_bulk = []
# for version in data["versions"]:
# orig_version_tags = version["data"].get("tags") or []
# version_tags = [tag for tag in orig_version_tags]
# if "deleted" not in version_tags:
# version_tags.append("deleted")
#
# if version_tags == orig_version_tags:
# continue
#
# update_query = {"id": version["id"]}
# update_data = {"$set": {"data.tags": version_tags}}
# mongo_changes_bulk.append(UpdateOne(update_query, update_data))
#
# if data["archive_product"]:
# mongo_changes_bulk.append(UpdateOne(
# {
# "id": data["product"]["id"],
# "type": "subset"
# },
# {"$set": {"type": "archived_subset"}}
# ))
#
# if mongo_changes_bulk:
# dbcon = AvalonMongoDB()
# dbcon.Session["AYON_PROJECT_NAME"] = project_name
# dbcon.install()
# dbcon.bulk_write(mongo_changes_bulk)
# dbcon.uninstall()
#
# self._ftrack_delete_versions(data)
#
# return size
#
# def _ftrack_delete_versions(self, data):
# """Delete version on ftrack.
#
# Handling of ftrack logic in this plugin is not ideal. But in OP3 it is
# almost impossible to solve the issue other way.
#
# Note:
# Asset versions on ftrack are not deleted but marked as
# "not published" which cause that they're invisible.
#
# Args:
# data (dict): Data sent to product loader with full context.
# """
#
# # First check for ftrack id on folder entity
# # - skip if ther is none
# ftrack_id = data["folder"]["attrib"].get("ftrackId")
# if not ftrack_id:
# self.log.info((
# "Folder does not have filled ftrack id. Skipped delete"
# " of ftrack version."
# ))
# return
#
# # Check if ftrack module is enabled
# addons_manager = AddonsManager()
# ftrack_addon = addons_manager.get("ftrack")
# if not ftrack_addon or not ftrack_addon.enabled:
# return
#
# import ftrack_api
#
# session = ftrack_api.Session()
# product_name = data["product"]["name"]
# versions = {
# '"{}"'.format(version_doc["name"])
# for version_doc in data["versions"]
# }
# asset_versions = session.query(
# (
# "select id, is_published from AssetVersion where"
# " asset.parent.id is \"{}\""
# " and asset.name is \"{}\""
# " and version in ({})"
# ).format(
# ftrack_id,
# product_name,
# ",".join(versions)
# )
# ).all()
#
# # Set attribute `is_published` to `False` on ftrack AssetVersions
# for asset_version in asset_versions:
# asset_version["is_published"] = False
#
# try:
# session.commit()
#
# except Exception:
# msg = (
# "Could not set `is_published` attribute to `False`"
# " for selected AssetVersions."
# )
# self.log.error(msg)
# self.message(msg)
#
# def load(self, contexts, name=None, namespace=None, options=None):
# try:
# size = 0
# for count, context in enumerate(contexts):
# versions_to_keep = 2
# remove_publish_folder = False
# if options:
# versions_to_keep = options.get(
# "versions_to_keep", versions_to_keep
# )
# remove_publish_folder = options.get(
# "remove_publish_folder", remove_publish_folder
# )
#
# data = self.get_data(context, versions_to_keep)
# if not data:
# continue
#
# project_name = context["project"]["name"]
# size += self.main(project_name, data, remove_publish_folder)
# print("Progressing {}/{}".format(count + 1, len(contexts)))
#
# msg = "Total size of files: {}".format(format_file_size(size))
# self.log.info(msg)
# self.message(msg)
#
# except Exception:
# self.log.error("Failed to delete versions.", exc_info=True)
#
#
# class CalculateOldVersions(DeleteOldVersions):
# """Calculate file size of old versions"""
# label = "Calculate Old Versions"
# order = 30
# tool_names = ["library_loader"]
#
# options = [
# qargparse.Integer(
# "versions_to_keep", default=2, min=0, help="Versions to keep:"
# ),
# qargparse.Boolean(
# "remove_publish_folder", help="Remove publish folder:"
# )
# ]
#
# def main(self, project_name, data, remove_publish_folder):
# size = 0
#
# if not data:
# return size
#
# if remove_publish_folder:
# size = self.delete_whole_dir_paths(
# data["dir_paths"].values(), delete=False
# )
# else:
# size = self.delete_only_repre_files(
# data["dir_paths"], data["file_paths_by_dir"], delete=False
# )
#
# return size
import collections
import os
import uuid
import clique
import ayon_api
from ayon_api.operations import OperationsSession
import qargparse
from qtpy import QtWidgets, QtCore
from ayon_core import style
from ayon_core.lib import format_file_size
from ayon_core.pipeline import load, Anatomy
from ayon_core.pipeline.load import (
get_representation_path_with_anatomy,
InvalidRepresentationContext,
)
class DeleteOldVersions(load.ProductLoaderPlugin):
"""Deletes specific number of old version"""
is_multiple_contexts_compatible = True
sequence_splitter = "__sequence_splitter__"
representations = ["*"]
product_types = {"*"}
tool_names = ["library_loader"]
label = "Delete Old Versions"
order = 35
icon = "trash"
color = "#d8d8d8"
options = [
qargparse.Integer(
"versions_to_keep", default=2, min=0, help="Versions to keep:"
),
qargparse.Boolean(
"remove_publish_folder", help="Remove publish folder:"
)
]
def delete_whole_dir_paths(self, dir_paths, delete=True):
size = 0
for dir_path in dir_paths:
# Delete all files and fodlers in dir path
for root, dirs, files in os.walk(dir_path, topdown=False):
for name in files:
file_path = os.path.join(root, name)
size += os.path.getsize(file_path)
if delete:
os.remove(file_path)
self.log.debug("Removed file: {}".format(file_path))
for name in dirs:
if delete:
os.rmdir(os.path.join(root, name))
if not delete:
continue
# Delete even the folder and it's parents folders if they are empty
while True:
if not os.path.exists(dir_path):
dir_path = os.path.dirname(dir_path)
continue
if len(os.listdir(dir_path)) != 0:
break
os.rmdir(os.path.join(dir_path))
return size
def path_from_representation(self, representation, anatomy):
try:
context = representation["context"]
except KeyError:
return (None, None)
try:
path = get_representation_path_with_anatomy(
representation, anatomy
)
except InvalidRepresentationContext:
return (None, None)
sequence_path = None
if "frame" in context:
context["frame"] = self.sequence_splitter
sequence_path = get_representation_path_with_anatomy(
representation, anatomy
)
if sequence_path:
sequence_path = sequence_path.normalized()
return (path.normalized(), sequence_path)
def delete_only_repre_files(self, dir_paths, file_paths, delete=True):
size = 0
for dir_id, dir_path in dir_paths.items():
dir_files = os.listdir(dir_path)
collections, remainders = clique.assemble(dir_files)
for file_path, seq_path in file_paths[dir_id]:
file_path_base = os.path.split(file_path)[1]
# Just remove file if `frame` key was not in context or
# filled path is in remainders (single file sequence)
if not seq_path or file_path_base in remainders:
if not os.path.exists(file_path):
self.log.debug(
"File was not found: {}".format(file_path)
)
continue
size += os.path.getsize(file_path)
if delete:
os.remove(file_path)
self.log.debug("Removed file: {}".format(file_path))
if file_path_base in remainders:
remainders.remove(file_path_base)
continue
seq_path_base = os.path.split(seq_path)[1]
head, tail = seq_path_base.split(self.sequence_splitter)
final_col = None
for collection in collections:
if head != collection.head or tail != collection.tail:
continue
final_col = collection
break
if final_col is not None:
# Fill full path to head
final_col.head = os.path.join(dir_path, final_col.head)
for _file_path in final_col:
if os.path.exists(_file_path):
size += os.path.getsize(_file_path)
if delete:
os.remove(_file_path)
self.log.debug(
"Removed file: {}".format(_file_path)
)
_seq_path = final_col.format("{head}{padding}{tail}")
self.log.debug("Removed files: {}".format(_seq_path))
collections.remove(final_col)
elif os.path.exists(file_path):
size += os.path.getsize(file_path)
if delete:
os.remove(file_path)
self.log.debug("Removed file: {}".format(file_path))
else:
self.log.debug(
"File was not found: {}".format(file_path)
)
# Delete as much as possible parent folders
if not delete:
return size
for dir_path in dir_paths.values():
while True:
if not os.path.exists(dir_path):
dir_path = os.path.dirname(dir_path)
continue
if len(os.listdir(dir_path)) != 0:
break
self.log.debug("Removed folder: {}".format(dir_path))
os.rmdir(dir_path)
return size
def message(self, text):
msgBox = QtWidgets.QMessageBox()
msgBox.setText(text)
msgBox.setStyleSheet(style.load_stylesheet())
msgBox.setWindowFlags(
msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint
)
msgBox.exec_()
def get_data(self, context, versions_count):
product_entity = context["product"]
folder_entity = context["folder"]
project_name = context["project"]["name"]
anatomy = Anatomy(project_name, project_entity=context["project"])
version_fields = ayon_api.get_default_fields_for_type("version")
version_fields.add("tags")
versions = list(ayon_api.get_versions(
project_name,
product_ids=[product_entity["id"]],
active=None,
hero=False,
fields=version_fields
))
self.log.debug(
"Version Number ({})".format(len(versions))
)
versions_by_parent = collections.defaultdict(list)
for ent in versions:
versions_by_parent[ent["productId"]].append(ent)
def sort_func(ent):
return int(ent["version"])
all_last_versions = []
for _parent_id, _versions in versions_by_parent.items():
for idx, version in enumerate(
sorted(_versions, key=sort_func, reverse=True)
):
if idx >= versions_count:
break
all_last_versions.append(version)
self.log.debug("Collected versions ({})".format(len(versions)))
# Filter latest versions
for version in all_last_versions:
versions.remove(version)
# Update versions_by_parent without filtered versions
versions_by_parent = collections.defaultdict(list)
for ent in versions:
versions_by_parent[ent["productId"]].append(ent)
# Filter already deleted versions
versions_to_pop = []
for version in versions:
if "deleted" in version["tags"]:
versions_to_pop.append(version)
for version in versions_to_pop:
msg = "Folder: \"{}\" | Product: \"{}\" | Version: \"{}\"".format(
folder_entity["path"],
product_entity["name"],
version["version"]
)
self.log.debug((
"Skipping version. Already tagged as inactive. < {} >"
).format(msg))
versions.remove(version)
version_ids = [ent["id"] for ent in versions]
self.log.debug(
"Filtered versions to delete ({})".format(len(version_ids))
)
if not version_ids:
msg = "Skipping processing. Nothing to delete on {}/{}".format(
folder_entity["path"], product_entity["name"]
)
self.log.info(msg)
print(msg)
return
repres = list(ayon_api.get_representations(
project_name, version_ids=version_ids
))
self.log.debug(
"Collected representations to remove ({})".format(len(repres))
)
dir_paths = {}
file_paths_by_dir = collections.defaultdict(list)
for repre in repres:
file_path, seq_path = self.path_from_representation(
repre, anatomy
)
if file_path is None:
self.log.debug((
"Could not format path for represenation \"{}\""
).format(str(repre)))
continue
dir_path = os.path.dirname(file_path)
dir_id = None
for _dir_id, _dir_path in dir_paths.items():
if _dir_path == dir_path:
dir_id = _dir_id
break
if dir_id is None:
dir_id = uuid.uuid4()
dir_paths[dir_id] = dir_path
file_paths_by_dir[dir_id].append([file_path, seq_path])
dir_ids_to_pop = []
for dir_id, dir_path in dir_paths.items():
if os.path.exists(dir_path):
continue
dir_ids_to_pop.append(dir_id)
# Pop dirs from both dictionaries
for dir_id in dir_ids_to_pop:
dir_paths.pop(dir_id)
paths = file_paths_by_dir.pop(dir_id)
# TODO report of missing directories?
paths_msg = ", ".join([
"'{}'".format(path[0].replace("\\", "/")) for path in paths
])
self.log.debug((
"Folder does not exist. Deleting its files skipped: {}"
).format(paths_msg))
return {
"dir_paths": dir_paths,
"file_paths_by_dir": file_paths_by_dir,
"versions": versions,
"folder": folder_entity,
"product": product_entity,
"archive_product": versions_count == 0
}
def main(self, project_name, data, remove_publish_folder):
# Size of files.
size = 0
if not data:
return size
if remove_publish_folder:
size = self.delete_whole_dir_paths(data["dir_paths"].values())
else:
size = self.delete_only_repre_files(
data["dir_paths"], data["file_paths_by_dir"]
)
op_session = OperationsSession()
for version in data["versions"]:
orig_version_tags = version["tags"]
version_tags = list(orig_version_tags)
changes = {}
if "deleted" not in version_tags:
version_tags.append("deleted")
changes["tags"] = version_tags
if version["active"]:
changes["active"] = False
if not changes:
continue
op_session.update_entity(
project_name, "version", version["id"], changes
)
op_session.commit()
return size
def load(self, contexts, name=None, namespace=None, options=None):
try:
size = 0
for count, context in enumerate(contexts):
versions_to_keep = 2
remove_publish_folder = False
if options:
versions_to_keep = options.get(
"versions_to_keep", versions_to_keep
)
remove_publish_folder = options.get(
"remove_publish_folder", remove_publish_folder
)
data = self.get_data(context, versions_to_keep)
if not data:
continue
project_name = context["project"]["name"]
size += self.main(project_name, data, remove_publish_folder)
print("Progressing {}/{}".format(count + 1, len(contexts)))
msg = "Total size of files: {}".format(format_file_size(size))
self.log.info(msg)
self.message(msg)
except Exception:
self.log.error("Failed to delete versions.", exc_info=True)
class CalculateOldVersions(DeleteOldVersions):
"""Calculate file size of old versions"""
label = "Calculate Old Versions"
order = 30
tool_names = ["library_loader"]
options = [
qargparse.Integer(
"versions_to_keep", default=2, min=0, help="Versions to keep:"
),
qargparse.Boolean(
"remove_publish_folder", help="Remove publish folder:"
)
]
def main(self, project_name, data, remove_publish_folder):
size = 0
if not data:
return size
if remove_publish_folder:
size = self.delete_whole_dir_paths(
data["dir_paths"].values(), delete=False
)
else:
size = self.delete_only_repre_files(
data["dir_paths"], data["file_paths_by_dir"], delete=False
)
return size

View file

@ -167,7 +167,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
"uasset",
"blendScene",
"yeticacheUE",
"tycache"
"tycache",
"csv_ingest_file",
]
default_template_name = "publish"

View file

@ -1,239 +1,31 @@
import time
import collections
import warnings
InitInfo = collections.namedtuple(
"InitInfo",
["default_factory", "lifetime"]
from ayon_core.lib import CacheItem as _CacheItem
from ayon_core.lib import NestedCacheItem as _NestedCacheItem
# Cache classes were moved to `ayon_core.lib.cache`
class CacheItem(_CacheItem):
def __init__(self, *args, **kwargs):
warnings.warn(
"Used 'CacheItem' from deprecated location "
"'ayon_core.tools.common_models', use 'ayon_core.lib' instead.",
DeprecationWarning,
)
super().__init__(*args, **kwargs)
class NestedCacheItem(_NestedCacheItem):
def __init__(self, *args, **kwargs):
warnings.warn(
"Used 'NestedCacheItem' from deprecated location "
"'ayon_core.tools.common_models', use 'ayon_core.lib' instead.",
DeprecationWarning,
)
super().__init__(*args, **kwargs)
__all__ = (
"CacheItem",
"NestedCacheItem",
)
def _default_factory_func():
return None
class CacheItem:
"""Simple cache item with lifetime and default value.
Args:
default_factory (Optional[callable]): Function that returns default
value used on init and on reset.
lifetime (Optional[int]): Lifetime of the cache data in seconds.
"""
def __init__(self, default_factory=None, lifetime=None):
if lifetime is None:
lifetime = 120
self._lifetime = lifetime
self._last_update = None
if default_factory is None:
default_factory = _default_factory_func
self._default_factory = default_factory
self._data = default_factory()
@property
def is_valid(self):
"""Is cache valid to use.
Return:
bool: True if cache is valid, False otherwise.
"""
if self._last_update is None:
return False
return (time.time() - self._last_update) < self._lifetime
def set_lifetime(self, lifetime):
"""Change lifetime of cache item.
Args:
lifetime (int): Lifetime of the cache data in seconds.
"""
self._lifetime = lifetime
def set_invalid(self):
"""Set cache as invalid."""
self._last_update = None
def reset(self):
"""Set cache as invalid and reset data."""
self._last_update = None
self._data = self._default_factory()
def get_data(self):
"""Receive cached data.
Returns:
Any: Any data that are cached.
"""
return self._data
def update_data(self, data):
self._data = data
self._last_update = time.time()
class NestedCacheItem:
"""Helper for cached items stored in nested structure.
Example:
>>> cache = NestedCacheItem(levels=2, default_factory=lambda: 0)
>>> cache["a"]["b"].is_valid
False
>>> cache["a"]["b"].get_data()
0
>>> cache["a"]["b"] = 1
>>> cache["a"]["b"].is_valid
True
>>> cache["a"]["b"].get_data()
1
>>> cache.reset()
>>> cache["a"]["b"].is_valid
False
Args:
levels (int): Number of nested levels where read cache is stored.
default_factory (Optional[callable]): Function that returns default
value used on init and on reset.
lifetime (Optional[int]): Lifetime of the cache data in seconds.
_init_info (Optional[InitInfo]): Private argument. Init info for
nested cache where created from parent item.
"""
def __init__(
self, levels=1, default_factory=None, lifetime=None, _init_info=None
):
if levels < 1:
raise ValueError("Nested levels must be greater than 0")
self._data_by_key = {}
if _init_info is None:
_init_info = InitInfo(default_factory, lifetime)
self._init_info = _init_info
self._levels = levels
def __getitem__(self, key):
"""Get cached data.
Args:
key (str): Key of the cache item.
Returns:
Union[NestedCacheItem, CacheItem]: Cache item.
"""
cache = self._data_by_key.get(key)
if cache is None:
if self._levels > 1:
cache = NestedCacheItem(
levels=self._levels - 1,
_init_info=self._init_info
)
else:
cache = CacheItem(
self._init_info.default_factory,
self._init_info.lifetime
)
self._data_by_key[key] = cache
return cache
def __setitem__(self, key, value):
"""Update cached data.
Args:
key (str): Key of the cache item.
value (Any): Any data that are cached.
"""
if self._levels > 1:
raise AttributeError((
"{} does not support '__setitem__'. Lower nested level by {}"
).format(self.__class__.__name__, self._levels - 1))
cache = self[key]
cache.update_data(value)
def get(self, key):
"""Get cached data.
Args:
key (str): Key of the cache item.
Returns:
Union[NestedCacheItem, CacheItem]: Cache item.
"""
return self[key]
def cached_count(self):
"""Amount of cached items.
Returns:
int: Amount of cached items.
"""
return len(self._data_by_key)
def clear_key(self, key):
"""Clear cached item by key.
Args:
key (str): Key of the cache item.
"""
self._data_by_key.pop(key, None)
def clear_invalid(self):
"""Clear all invalid cache items.
Note:
To clear all cache items use 'reset'.
"""
changed = {}
children_are_nested = self._levels > 1
for key, cache in tuple(self._data_by_key.items()):
if children_are_nested:
output = cache.clear_invalid()
if output:
changed[key] = output
if not cache.cached_count():
self._data_by_key.pop(key)
elif not cache.is_valid:
changed[key] = cache.get_data()
self._data_by_key.pop(key)
return changed
def reset(self):
"""Reset cache.
Note:
To clear only invalid cache items use 'clear_invalid'.
"""
self._data_by_key = {}
def set_lifetime(self, lifetime):
"""Change lifetime of all children cache items.
Args:
lifetime (int): Lifetime of the cache data in seconds.
"""
self._init_info.lifetime = lifetime
for cache in self._data_by_key.values():
cache.set_lifetime(lifetime)
@property
def is_valid(self):
"""Raise reasonable error when called on wront level.
Raises:
AttributeError: If called on nested cache item.
"""
raise AttributeError((
"{} does not support 'is_valid'. Lower nested level by '{}'"
).format(self.__class__.__name__, self._levels))

View file

@ -6,8 +6,7 @@ import ayon_api
import six
from ayon_core.style import get_default_entity_icon_color
from .cache import NestedCacheItem
from ayon_core.lib import NestedCacheItem
HIERARCHY_MODEL_SENDER = "hierarchy.model"

View file

@ -5,8 +5,7 @@ import ayon_api
import six
from ayon_core.style import get_default_entity_icon_color
from .cache import CacheItem
from ayon_core.lib import CacheItem
PROJECTS_MODEL_SENDER = "projects.model"

View file

@ -5,7 +5,7 @@ import collections
import ayon_api
import appdirs
from .cache import NestedCacheItem
from ayon_core.lib import NestedCacheItem
FileInfo = collections.namedtuple(
"FileInfo",

View file

@ -6,6 +6,7 @@ import uuid
import ayon_api
from ayon_core.lib import NestedCacheItem
from ayon_core.pipeline.load import (
discover_loader_plugins,
ProductLoaderPlugin,
@ -17,7 +18,6 @@ from ayon_core.pipeline.load import (
LoadError,
IncompatibleLoaderError,
)
from ayon_core.tools.common_models import NestedCacheItem
from ayon_core.tools.loader.abstract import ActionItem
ACTIONS_MODEL_SENDER = "actions.model"

View file

@ -5,8 +5,8 @@ import arrow
import ayon_api
from ayon_api.operations import OperationsSession
from ayon_core.lib import NestedCacheItem
from ayon_core.style import get_default_entity_icon_color
from ayon_core.tools.common_models import NestedCacheItem
from ayon_core.tools.loader.abstract import (
ProductTypeItem,
ProductItem,

View file

@ -2,9 +2,8 @@ import collections
from ayon_api import get_representations, get_versions_links
from ayon_core.lib import Logger
from ayon_core.lib import Logger, NestedCacheItem
from ayon_core.addon import AddonsManager
from ayon_core.tools.common_models import NestedCacheItem
from ayon_core.tools.loader.abstract import ActionItem
DOWNLOAD_IDENTIFIER = "sitesync.download"

View file

@ -8,12 +8,12 @@ from ayon_core.tools.utils.dialogs import show_message_dialog
def open_template_ui(builder, main_window):
"""Open template from `builder`
Asks user about overwriting current scene and feedsback exceptions.
Asks user about overwriting current scene and feedback exceptions.
"""
result = QtWidgets.QMessageBox.question(
main_window,
"Opening template",
"Caution! You will loose unsaved changes.\nDo you want to continue?",
"Caution! You will lose unsaved changes.\nDo you want to continue?",
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No
)
if result == QtWidgets.QMessageBox.Yes:

View file

@ -20,6 +20,8 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel):
controller (AbstractWorkfilesFrontend): The control object.
"""
refreshed = QtCore.Signal()
def __init__(self, controller):
super(WorkAreaFilesModel, self).__init__()
@ -163,6 +165,12 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel):
self._fill_items()
def _fill_items(self):
try:
self._fill_items_impl()
finally:
self.refreshed.emit()
def _fill_items_impl(self):
folder_id = self._selected_folder_id
task_id = self._selected_task_id
if not folder_id or not task_id:
@ -285,6 +293,7 @@ class WorkAreaFilesWidget(QtWidgets.QWidget):
selection_model.selectionChanged.connect(self._on_selection_change)
view.double_clicked.connect(self._on_mouse_double_click)
view.customContextMenuRequested.connect(self._on_context_menu)
model.refreshed.connect(self._on_model_refresh)
controller.register_event_callback(
"expected_selection_changed",
@ -298,6 +307,7 @@ class WorkAreaFilesWidget(QtWidgets.QWidget):
self._controller = controller
self._published_mode = False
self._change_selection_on_refresh = True
def set_published_mode(self, published_mode):
"""Set the published mode.
@ -379,7 +389,9 @@ class WorkAreaFilesWidget(QtWidgets.QWidget):
if not workfile_info["current"]:
return
self._change_selection_on_refresh = False
self._model.refresh()
self._change_selection_on_refresh = True
workfile_name = workfile_info["name"]
if (
@ -394,3 +406,30 @@ class WorkAreaFilesWidget(QtWidgets.QWidget):
self._controller.expected_workfile_selected(
event["folder"]["id"], event["task"]["name"], workfile_name
)
def _on_model_refresh(self):
if (
not self._change_selection_on_refresh
or self._proxy_model.rowCount() < 1
):
return
# Find the row with latest date modified
latest_index = max(
(
self._proxy_model.index(idx, 0)
for idx in range(self._proxy_model.rowCount())
),
key=lambda model_index: model_index.data(DATE_MODIFIED_ROLE)
)
# Select row of latest modified
selection_model = self._view.selectionModel()
selection_model.select(
latest_index,
(
QtCore.QItemSelectionModel.ClearAndSelect
| QtCore.QItemSelectionModel.Current
| QtCore.QItemSelectionModel.Rows
)
)

View file

@ -118,11 +118,11 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
overlay_invalid_host = InvalidHostOverlay(self)
overlay_invalid_host.setVisible(False)
first_show_timer = QtCore.QTimer()
first_show_timer.setSingleShot(True)
first_show_timer.setInterval(50)
show_timer = QtCore.QTimer()
show_timer.setSingleShot(True)
show_timer.setInterval(50)
first_show_timer.timeout.connect(self._on_first_show)
show_timer.timeout.connect(self._on_show)
controller.register_event_callback(
"save_as.finished",
@ -159,7 +159,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
self._tasks_widget = tasks_widget
self._side_panel = side_panel
self._first_show_timer = first_show_timer
self._show_timer = show_timer
self._post_init()
@ -287,9 +287,9 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
def showEvent(self, event):
super(WorkfilesToolWindow, self).showEvent(event)
self._show_timer.start()
if self._first_show:
self._first_show = False
self._first_show_timer.start()
self.setStyleSheet(style.load_stylesheet())
def keyPressEvent(self, event):
@ -303,9 +303,8 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
pass
def _on_first_show(self):
if not self._controller_refreshed:
self.refresh()
def _on_show(self):
self.refresh()
def _on_file_text_filter_change(self, text):
self._files_widget.set_text_filter(text)

View file

@ -77,6 +77,20 @@ unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
exclude = [
"client/ayon_core/hosts/unreal/integration/*",
"client/ayon_core/hosts/aftereffects/api/extension/js/libs/*",
"client/ayon_core/hosts/hiero/api/startup/*",
"client/ayon_core/modules/deadline/repository/custom/plugins/CelAction/*",
"client/ayon_core/modules/deadline/repository/custom/plugins/HarmonyAYON/*",
"client/ayon_core/modules/click_wrap.py",
"client/ayon_core/scripts/slates/__init__.py"
]
[tool.ruff.lint.per-file-ignores]
"client/ayon_core/lib/__init__.py" = ["E402"]
"client/ayon_core/hosts/max/startup/startup.py" = ["E402"]
[tool.ruff.format]
# Like Black, use double quotes for strings.
quote-style = "double"
@ -92,7 +106,7 @@ line-ending = "auto"
[tool.codespell]
# Ignore words that are not in the dictionary.
ignore-words-list = "ayon,ynput,parms,parm,hda,developpement"
ignore-words-list = "ayon,ynput,parms,parm,hda,developpement,ue"
skip = "./.*,./package/*,*/vendor/*,*/unreal/integration/*,*/aftereffects/api/extension/js/libs/*"
count = true

View file

@ -0,0 +1,3 @@
name = "aftereffects"
title = "AfterEffects"
version = "0.1.3"

View file

@ -1,14 +1,9 @@
from ayon_server.addons import BaseServerAddon
from .settings import AfterEffectsSettings, DEFAULT_AFTEREFFECTS_SETTING
from .version import __version__
class AfterEffects(BaseServerAddon):
name = "aftereffects"
title = "AfterEffects"
version = __version__
settings_model = AfterEffectsSettings
async def get_default_settings(self):

View file

@ -1,3 +0,0 @@
# -*- coding: utf-8 -*-
"""Package declaring addon version."""
__version__ = "0.1.3"

View file

@ -110,6 +110,26 @@ class ApplicationsAddon(AYONAddon, IPluginPaths):
]
}
def launch_application(
self, app_name, project_name, folder_path, task_name
):
"""Launch application.
Args:
app_name (str): Full application name e.g. 'maya/2024'.
project_name (str): Project name.
folder_path (str): Folder path.
task_name (str): Task name.
"""
app_manager = self.get_applications_manager()
return app_manager.launch(
app_name,
project_name=project_name,
folder_path=folder_path,
task_name=task_name,
)
# --- CLI ---
def cli(self, addon_click_group):
main_group = click_wrap.group(
@ -134,6 +154,17 @@ class ApplicationsAddon(AYONAddon, IPluginPaths):
default=None
)
)
(
main_group.command(
self._cli_launch_applications,
name="launch",
help="Launch application"
)
.option("--app", required=True, help="Application name")
.option("--project", required=True, help="Project name")
.option("--folder", required=True, help="Folder path")
.option("--task", required=True, help="Task name")
)
# Convert main command to click object and add it to parent group
addon_click_group.add_command(
main_group.to_click_obj()
@ -171,3 +202,15 @@ class ApplicationsAddon(AYONAddon, IPluginPaths):
with open(output_json_path, "w") as file_stream:
json.dump(env, file_stream, indent=4)
def _cli_launch_applications(self, project, folder, task, app):
"""Launch application.
Args:
project (str): Project name.
folder (str): Folder path.
task (str): Task name.
app (str): Full application name e.g. 'maya/2024'.
"""
self.launch_application(app, project, folder, task)

Some files were not shown because too many files have changed in this diff Show more