Merge branch 'develop' of https://github.com/ynput/ayon-core into enhancement/houdini_load_asset_lop

This commit is contained in:
Roy Nieterau 2024-06-28 01:01:39 +02:00
commit c086a01804
636 changed files with 7 additions and 128715 deletions

View file

@ -4,7 +4,7 @@ import inspect
import traceback
import collections
from functools import partial
from typing import Optional, Dict, List, Union, Any, Iterable, Literal
from typing import Optional, Dict, List, Union, Any, Iterable
import arrow
import pyblish.plugin
@ -22,15 +22,6 @@ PUBLISH_EVENT_SOURCE = "publisher.publish.model"
# Define constant for plugin orders offset
PLUGIN_ORDER_OFFSET = 0.5
ActionFilterType = Literal[
"all",
"notProcessed",
"processed",
"failed",
"warning",
"failedOrWarning",
"succeeded"
]
class PublishReportMaker:
@ -318,8 +309,10 @@ class PublishPluginActionItem:
action_id (str): Action id.
plugin_id (str): Plugin id.
active (bool): Action is active.
on_filter (ActionFilterType): Actions have 'on' attribute which define
when can be action triggered (e.g. 'all', 'failed', ...).
on_filter (Literal["all", "notProcessed", "processed", "failed",
"warning", "failedOrWarning", "succeeded"]): Actions have 'on'
attribute which define when can be action triggered
(e.g. 'all', 'failed', ...).
label (str): Action's label.
icon (Optional[str]) Action's icon.
"""
@ -329,14 +322,14 @@ class PublishPluginActionItem:
action_id: str,
plugin_id: str,
active: bool,
on_filter: ActionFilterType,
on_filter: str,
label: str,
icon: Optional[str],
):
self.action_id: str = action_id
self.plugin_id: str = plugin_id
self.active: bool = active
self.on_filter: ActionFilterType = on_filter
self.on_filter: str = on_filter
self.label: str = label
self.icon: Optional[str] = icon

View file

@ -1,62 +0,0 @@
from .version import __version__
from .constants import (
APPLICATIONS_ADDON_ROOT,
DEFAULT_ENV_SUBGROUP,
PLATFORM_NAMES,
)
from .exceptions import (
ApplicationNotFound,
ApplicationExecutableNotFound,
ApplicationLaunchFailed,
MissingRequiredKey,
)
from .defs import (
LaunchTypes,
ApplicationExecutable,
UndefinedApplicationExecutable,
ApplicationGroup,
Application,
EnvironmentToolGroup,
EnvironmentTool,
)
from .hooks import (
LaunchHook,
PreLaunchHook,
PostLaunchHook,
)
from .manager import (
ApplicationManager,
ApplicationLaunchContext,
)
from .addon import ApplicationsAddon
__all__ = (
"__version__",
"APPLICATIONS_ADDON_ROOT",
"DEFAULT_ENV_SUBGROUP",
"PLATFORM_NAMES",
"ApplicationNotFound",
"ApplicationExecutableNotFound",
"ApplicationLaunchFailed",
"MissingRequiredKey",
"LaunchTypes",
"ApplicationExecutable",
"UndefinedApplicationExecutable",
"ApplicationGroup",
"Application",
"EnvironmentToolGroup",
"EnvironmentTool",
"LaunchHook",
"PreLaunchHook",
"PostLaunchHook",
"ApplicationManager",
"ApplicationLaunchContext",
"ApplicationsAddon",
)

View file

@ -1,147 +0,0 @@
import copy
import ayon_api
from ayon_core import resources
from ayon_core.lib import Logger, NestedCacheItem
from ayon_core.settings import get_studio_settings, get_project_settings
from ayon_core.pipeline.actions import LauncherAction
from .exceptions import (
ApplicationExecutableNotFound,
ApplicationLaunchFailed,
)
class ApplicationAction(LauncherAction):
"""Action to launch an application.
Application action based on 'ApplicationManager' system.
Handling of applications in launcher is not ideal and should be completely
redone from scratch. This is just a temporary solution to keep backwards
compatibility with AYON launcher.
Todos:
Move handling of errors to frontend.
"""
# Application object
application = None
# Action attributes
name = None
label = None
label_variant = None
group = None
icon = None
color = None
order = 0
data = {}
project_settings = {}
project_entities = {}
_log = None
# --- For compatibility for combinations of new and old ayon-core ---
project_settings_cache = NestedCacheItem(
levels=1, default_factory=dict, lifetime=20
)
project_entities_cache = NestedCacheItem(
levels=1, default_factory=dict, lifetime=20
)
@classmethod
def _app_get_project_settings(cls, selection):
project_name = selection.project_name
if project_name in ApplicationAction.project_settings:
return ApplicationAction.project_settings[project_name]
if hasattr(selection, "get_project_settings"):
return selection.get_project_settings()
cache = ApplicationAction.project_settings_cache[project_name]
if not cache.is_valid:
if project_name:
settings = get_project_settings(project_name)
else:
settings = get_studio_settings()
cache.update_data(settings)
return copy.deepcopy(cache.get_data())
@classmethod
def _app_get_project_entity(cls, selection):
project_name = selection.project_name
if project_name in ApplicationAction.project_entities:
return ApplicationAction.project_entities[project_name]
if hasattr(selection, "get_project_settings"):
return selection.get_project_entity()
cache = ApplicationAction.project_entities_cache[project_name]
if not cache.is_valid:
project_entity = None
if project_name:
project_entity = ayon_api.get_project(project_name)
cache.update_data(project_entity)
return copy.deepcopy(cache.get_data())
@property
def log(self):
if self._log is None:
self._log = Logger.get_logger(self.__class__.__name__)
return self._log
def is_compatible(self, selection):
if not selection.is_task_selected:
return False
project_entity = self._app_get_project_entity(selection)
apps = project_entity["attrib"].get("applications")
if not apps or self.application.full_name not in apps:
return False
project_settings = self._app_get_project_settings(selection)
only_available = project_settings["applications"]["only_available"]
if only_available and not self.application.find_executable():
return False
return True
def _show_message_box(self, title, message, details=None):
from qtpy import QtWidgets, QtGui
from ayon_core import style
dialog = QtWidgets.QMessageBox()
icon = QtGui.QIcon(resources.get_ayon_icon_filepath())
dialog.setWindowIcon(icon)
dialog.setStyleSheet(style.load_stylesheet())
dialog.setWindowTitle(title)
dialog.setText(message)
if details:
dialog.setDetailedText(details)
dialog.exec_()
def process(self, selection, **kwargs):
"""Process the full Application action"""
try:
self.application.launch(
project_name=selection.project_name,
folder_path=selection.folder_path,
task_name=selection.task_name,
**self.data
)
except ApplicationExecutableNotFound as exc:
details = exc.details
msg = exc.msg
log_msg = str(msg)
if details:
log_msg += "\n" + details
self.log.warning(log_msg)
self._show_message_box(
"Application executable not found", msg, details
)
except ApplicationLaunchFailed as exc:
msg = str(exc)
self.log.warning(msg, exc_info=True)
self._show_message_box("Application launch failed", msg)

View file

@ -1,321 +0,0 @@
import os
import json
import ayon_api
from ayon_core.addon import AYONAddon, IPluginPaths, click_wrap
from .version import __version__
from .constants import APPLICATIONS_ADDON_ROOT
from .defs import LaunchTypes
from .manager import ApplicationManager
class ApplicationsAddon(AYONAddon, IPluginPaths):
name = "applications"
version = __version__
def initialize(self, settings):
# TODO remove when addon is removed from ayon-core
self.enabled = self.name in settings
def get_app_environments_for_context(
self,
project_name,
folder_path,
task_name,
full_app_name,
env_group=None,
launch_type=None,
env=None,
):
"""Calculate environment variables for launch context.
Args:
project_name (str): Project name.
folder_path (str): Folder path.
task_name (str): Task name.
full_app_name (str): Full application name.
env_group (Optional[str]): Environment group.
launch_type (Optional[str]): Launch type.
env (Optional[dict[str, str]]): Environment variables to update.
Returns:
dict[str, str]: Environment variables for context.
"""
from ayon_applications.utils import get_app_environments_for_context
if not full_app_name:
return {}
return get_app_environments_for_context(
project_name,
folder_path,
task_name,
full_app_name,
env_group=env_group,
launch_type=launch_type,
env=env,
addons_manager=self.manager
)
def get_farm_publish_environment_variables(
self,
project_name,
folder_path,
task_name,
full_app_name=None,
env_group=None,
):
"""Calculate environment variables for farm publish.
Args:
project_name (str): Project name.
folder_path (str): Folder path.
task_name (str): Task name.
env_group (Optional[str]): Environment group.
full_app_name (Optional[str]): Full application name. Value from
environment variable 'AYON_APP_NAME' is used if 'None' is
passed.
Returns:
dict[str, str]: Environment variables for farm publish.
"""
if full_app_name is None:
full_app_name = os.getenv("AYON_APP_NAME")
return self.get_app_environments_for_context(
project_name,
folder_path,
task_name,
full_app_name,
env_group=env_group,
launch_type=LaunchTypes.farm_publish
)
def get_applications_manager(self, settings=None):
"""Get applications manager.
Args:
settings (Optional[dict]): Studio/project settings.
Returns:
ApplicationManager: Applications manager.
"""
return ApplicationManager(settings)
def get_plugin_paths(self):
return {
"publish": [
os.path.join(APPLICATIONS_ADDON_ROOT, "plugins", "publish")
]
}
def get_app_icon_path(self, icon_filename):
"""Get icon path.
Args:
icon_filename (str): Icon filename.
Returns:
Union[str, None]: Icon path or None if not found.
"""
if not icon_filename:
return None
icon_name = os.path.basename(icon_filename)
path = os.path.join(APPLICATIONS_ADDON_ROOT, "icons", icon_name)
if os.path.exists(path):
return path
return None
def get_app_icon_url(self, icon_filename, server=False):
"""Get icon path.
Method does not validate if icon filename exist on server.
Args:
icon_filename (str): Icon name.
server (Optional[bool]): Return url to AYON server.
Returns:
Union[str, None]: Icon path or None is server url is not
available.
"""
if not icon_filename:
return None
icon_name = os.path.basename(icon_filename)
if server:
base_url = ayon_api.get_base_url()
return (
f"{base_url}/addons/{self.name}/{self.version}"
f"/public/icons/{icon_name}"
)
server_url = os.getenv("AYON_WEBSERVER_URL")
if not server_url:
return None
return "/".join([
server_url, "addons", self.name, self.version, "icons", icon_name
])
def get_applications_action_classes(self):
"""Get application action classes for launcher tool.
This method should be used only by launcher tool. Please do not use it
in other places as its implementation is not optimal, and might
change or be removed.
Returns:
list[ApplicationAction]: List of application action classes.
"""
from .action import ApplicationAction
actions = []
manager = self.get_applications_manager()
for full_name, application in manager.applications.items():
if not application.enabled:
continue
icon = self.get_app_icon_path(application.icon)
action = type(
"app_{}".format(full_name),
(ApplicationAction,),
{
"identifier": "application.{}".format(full_name),
"application": application,
"name": application.name,
"label": application.group.label,
"label_variant": application.label,
"group": None,
"icon": icon,
"color": getattr(application, "color", None),
"order": getattr(application, "order", None) or 0,
"data": {}
}
)
actions.append(action)
return actions
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,
)
def webserver_initialization(self, manager):
"""Initialize webserver.
Args:
manager (WebServerManager): Webserver manager.
"""
static_prefix = f"/addons/{self.name}/{self.version}/icons"
manager.add_static(
static_prefix, os.path.join(APPLICATIONS_ADDON_ROOT, "icons")
)
# --- CLI ---
def cli(self, addon_click_group):
main_group = click_wrap.group(
self._cli_main, name=self.name, help="Applications addon"
)
(
main_group.command(
self._cli_extract_environments,
name="extractenvironments",
help=(
"Extract environment variables for context into json file"
)
)
.argument("output_json_path")
.option("--project", help="Project name", default=None)
.option("--folder", help="Folder path", default=None)
.option("--task", help="Task name", default=None)
.option("--app", help="Application name", default=None)
.option(
"--envgroup",
help="Environment group (e.g. \"farm\")",
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()
)
def _cli_main(self):
pass
def _cli_extract_environments(
self, output_json_path, project, folder, task, app, envgroup
):
"""Produces json file with environment based on project and app.
Called by farm integration to propagate environment into farm jobs.
Args:
output_json_path (str): Output json file path.
project (str): Project name.
folder (str): Folder path.
task (str): Task name.
app (str): Full application name e.g. 'maya/2024'.
envgroup (str): Environment group.
"""
if all((project, folder, task, app)):
env = self.get_farm_publish_environment_variables(
project, folder, task, app, env_group=envgroup,
)
else:
env = os.environ.copy()
output_dir = os.path.dirname(output_json_path)
if not os.path.exists(output_dir):
os.makedirs(output_dir)
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)

View file

@ -1,6 +0,0 @@
import os
APPLICATIONS_ADDON_ROOT = os.path.dirname(os.path.abspath(__file__))
PLATFORM_NAMES = {"windows", "linux", "darwin"}
DEFAULT_ENV_SUBGROUP = "standard"

View file

@ -1,404 +0,0 @@
import os
import platform
import json
import copy
from ayon_core.lib import find_executable
class LaunchTypes:
"""Launch types are filters for pre/post-launch hooks.
Please use these variables in case they'll change values.
"""
# Local launch - application is launched on local machine
local = "local"
# Farm render job - application is on farm
farm_render = "farm-render"
# Farm publish job - integration post-render job
farm_publish = "farm-publish"
# Remote launch - application is launched on remote machine from which
# can be started publishing
remote = "remote"
# Automated launch - application is launched with automated publishing
automated = "automated"
class ApplicationExecutable:
"""Representation of executable loaded from settings."""
def __init__(self, executable):
# Try to format executable with environments
try:
executable = executable.format(**os.environ)
except Exception:
pass
# On MacOS check if exists path to executable when ends with `.app`
# - it is common that path will lead to "/Applications/Blender" but
# real path is "/Applications/Blender.app"
if platform.system().lower() == "darwin":
executable = self.macos_executable_prep(executable)
self.executable_path = executable
def __str__(self):
return self.executable_path
def __repr__(self):
return "<{}> {}".format(self.__class__.__name__, self.executable_path)
@staticmethod
def macos_executable_prep(executable):
"""Try to find full path to executable file.
Real executable is stored in '*.app/Contents/MacOS/<executable>'.
Having path to '*.app' gives ability to read it's plist info and
use "CFBundleExecutable" key from plist to know what is "executable."
Plist is stored in '*.app/Contents/Info.plist'.
This is because some '*.app' directories don't have same permissions
as real executable.
"""
# Try to find if there is `.app` file
if not os.path.exists(executable):
_executable = executable + ".app"
if os.path.exists(_executable):
executable = _executable
# Try to find real executable if executable has `Contents` subfolder
contents_dir = os.path.join(executable, "Contents")
if os.path.exists(contents_dir):
executable_filename = None
# Load plist file and check for bundle executable
plist_filepath = os.path.join(contents_dir, "Info.plist")
if os.path.exists(plist_filepath):
import plistlib
if hasattr(plistlib, "load"):
with open(plist_filepath, "rb") as stream:
parsed_plist = plistlib.load(stream)
else:
parsed_plist = plistlib.readPlist(plist_filepath)
executable_filename = parsed_plist.get("CFBundleExecutable")
if executable_filename:
executable = os.path.join(
contents_dir, "MacOS", executable_filename
)
return executable
def as_args(self):
return [self.executable_path]
def _realpath(self):
"""Check if path is valid executable path."""
# Check for executable in PATH
result = find_executable(self.executable_path)
if result is not None:
return result
# This is not 100% validation but it is better than remove ability to
# launch .bat, .sh or extentionless files
if os.path.exists(self.executable_path):
return self.executable_path
return None
def exists(self):
if not self.executable_path:
return False
return bool(self._realpath())
class UndefinedApplicationExecutable(ApplicationExecutable):
"""Some applications do not require executable path from settings.
In that case this class is used to "fake" existing executable.
"""
def __init__(self):
pass
def __str__(self):
return self.__class__.__name__
def __repr__(self):
return "<{}>".format(self.__class__.__name__)
def as_args(self):
return []
def exists(self):
return True
class ApplicationGroup:
"""Hold information about application group.
Application group wraps different versions(variants) of application.
e.g. "maya" is group and "maya_2020" is variant.
Group hold `host_name` which is implementation name used in AYON. Also
holds `enabled` if whole app group is enabled or `icon` for application
icon path in resources.
Group has also `environment` which hold same environments for all variants.
Args:
name (str): Groups' name.
data (dict): Group defying data loaded from settings.
manager (ApplicationManager): Manager that created the group.
"""
def __init__(self, name, data, manager):
self.name = name
self.manager = manager
self._data = data
self.enabled = data["enabled"]
self.label = data["label"] or None
self.icon = data["icon"] or None
env = {}
try:
env = json.loads(data["environment"])
except Exception:
pass
self._environment = env
host_name = data["host_name"] or None
self.is_host = host_name is not None
self.host_name = host_name
settings_variants = data["variants"]
variants = {}
for variant_data in settings_variants:
app_variant = Application(variant_data, self)
variants[app_variant.name] = app_variant
self.variants = variants
def __repr__(self):
return "<{}> - {}".format(self.__class__.__name__, self.name)
def __iter__(self):
for variant in self.variants.values():
yield variant
@property
def environment(self):
return copy.deepcopy(self._environment)
class Application:
"""Hold information about application.
Object by itself does nothing special.
Args:
data (dict): Data for the version containing information about
executables, variant label or if is enabled.
Only required key is `executables`.
group (ApplicationGroup): App group object that created the application
and under which application belongs.
"""
def __init__(self, data, group):
self._data = data
name = data["name"]
label = data["label"] or name
enabled = False
if group.enabled:
enabled = data.get("enabled", True)
if group.label:
full_label = " ".join((group.label, label))
else:
full_label = label
env = {}
try:
env = json.loads(data["environment"])
except Exception:
pass
arguments = data["arguments"]
if isinstance(arguments, dict):
arguments = arguments.get(platform.system().lower())
if not arguments:
arguments = []
_executables = data["executables"].get(platform.system().lower(), [])
executables = [
ApplicationExecutable(executable)
for executable in _executables
]
self.group = group
self.name = name
self.label = label
self.enabled = enabled
self.use_python_2 = data.get("use_python_2", False)
self.full_name = "/".join((group.name, name))
self.full_label = full_label
self.arguments = arguments
self.executables = executables
self._environment = env
def __repr__(self):
return "<{}> - {}".format(self.__class__.__name__, self.full_name)
@property
def environment(self):
return copy.deepcopy(self._environment)
@property
def manager(self):
return self.group.manager
@property
def host_name(self):
return self.group.host_name
@property
def icon(self):
return self.group.icon
@property
def is_host(self):
return self.group.is_host
def find_executable(self):
"""Try to find existing executable for application.
Returns (str): Path to executable from `executables` or None if any
exists.
"""
for executable in self.executables:
if executable.exists():
return executable
return None
def launch(self, *args, **kwargs):
"""Launch the application.
For this purpose is used manager's launch method to keep logic at one
place.
Arguments must match with manager's launch method. That's why *args
**kwargs are used.
Returns:
subprocess.Popen: Return executed process as Popen object.
"""
return self.manager.launch(self.full_name, *args, **kwargs)
class EnvironmentToolGroup:
"""Hold information about environment tool group.
Environment tool group may hold different variants of same tool and set
environments that are same for all of them.
e.g. "mtoa" may have different versions but all environments except one
are same.
Args:
data (dict): Group information with variants.
manager (ApplicationManager): Manager that creates the group.
"""
def __init__(self, data, manager):
name = data["name"]
label = data["label"]
self.name = name
self.label = label
self._data = data
self.manager = manager
environment = {}
try:
environment = json.loads(data["environment"])
except Exception:
pass
self._environment = environment
variants = data.get("variants") or []
variants_by_name = {}
for variant_data in variants:
tool = EnvironmentTool(variant_data, self)
variants_by_name[tool.name] = tool
self.variants = variants_by_name
def __repr__(self):
return "<{}> - {}".format(self.__class__.__name__, self.name)
def __iter__(self):
for variant in self.variants.values():
yield variant
@property
def environment(self):
return copy.deepcopy(self._environment)
class EnvironmentTool:
"""Hold information about application tool.
Structure of tool information.
Args:
variant_data (dict): Variant data with environments and
host and app variant filters.
group (EnvironmentToolGroup): Name of group which wraps tool.
"""
def __init__(self, variant_data, group):
# Backwards compatibility 3.9.1 - 3.9.2
# - 'variant_data' contained only environments but contain also host
# and application variant filters
name = variant_data["name"]
label = variant_data["label"]
host_names = variant_data["host_names"]
app_variants = variant_data["app_variants"]
environment = {}
try:
environment = json.loads(variant_data["environment"])
except Exception:
pass
self.host_names = host_names
self.app_variants = app_variants
self.name = name
self.variant_label = label
self.label = " ".join((group.label, label))
self.group = group
self._environment = environment
self.full_name = "/".join((group.name, name))
def __repr__(self):
return "<{}> - {}".format(self.__class__.__name__, self.full_name)
@property
def environment(self):
return copy.deepcopy(self._environment)
def is_valid_for_app(self, app):
"""Is tool valid for application.
Args:
app (Application): Application for which are prepared environments.
"""
if self.app_variants and app.full_name not in self.app_variants:
return False
if self.host_names and app.host_name not in self.host_names:
return False
return True

View file

@ -1,50 +0,0 @@
class ApplicationNotFound(Exception):
"""Application was not found in ApplicationManager by name."""
def __init__(self, app_name):
self.app_name = app_name
super(ApplicationNotFound, self).__init__(
"Application \"{}\" was not found.".format(app_name)
)
class ApplicationExecutableNotFound(Exception):
"""Defined executable paths are not available on the machine."""
def __init__(self, application):
self.application = application
details = None
if not application.executables:
msg = (
"Executable paths for application \"{}\"({}) are not set."
)
else:
msg = (
"Defined executable paths for application \"{}\"({})"
" are not available on this machine."
)
details = "Defined paths:"
for executable in application.executables:
details += "\n- " + executable.executable_path
self.msg = msg.format(application.full_label, application.full_name)
self.details = details
exc_mgs = str(self.msg)
if details:
# Is good idea to pass new line symbol to exception message?
exc_mgs += "\n" + details
self.exc_msg = exc_mgs
super(ApplicationExecutableNotFound, self).__init__(exc_mgs)
class ApplicationLaunchFailed(Exception):
"""Application launch failed due to known reason.
Message should be self explanatory as traceback won't be shown.
"""
pass
class MissingRequiredKey(KeyError):
pass

View file

@ -1,150 +0,0 @@
import platform
from abc import ABCMeta, abstractmethod
import six
from ayon_core.lib import Logger
from .defs import LaunchTypes
@six.add_metaclass(ABCMeta)
class LaunchHook:
"""Abstract base class of launch hook."""
# Order of prelaunch hook, will be executed as last if set to None.
order = None
# List of host implementations, skipped if empty.
hosts = set()
# Set of application groups
app_groups = set()
# Set of specific application names
app_names = set()
# Set of platform availability
platforms = set()
# Set of launch types for which is available
# - if empty then is available for all launch types
# - by default has 'local' which is most common reason for launc hooks
launch_types = {LaunchTypes.local}
def __init__(self, launch_context):
"""Constructor of launch hook.
Always should be called
"""
self.log = Logger.get_logger(self.__class__.__name__)
self.launch_context = launch_context
is_valid = self.class_validation(launch_context)
if is_valid:
is_valid = self.validate()
self.is_valid = is_valid
@classmethod
def class_validation(cls, launch_context):
"""Validation of class attributes by launch context.
Args:
launch_context (ApplicationLaunchContext): Context of launching
application.
Returns:
bool: Is launch hook valid for the context by class attributes.
"""
if cls.platforms:
low_platforms = tuple(
_platform.lower()
for _platform in cls.platforms
)
if platform.system().lower() not in low_platforms:
return False
if cls.hosts:
if launch_context.host_name not in cls.hosts:
return False
if cls.app_groups:
if launch_context.app_group.name not in cls.app_groups:
return False
if cls.app_names:
if launch_context.app_name not in cls.app_names:
return False
if cls.launch_types:
if launch_context.launch_type not in cls.launch_types:
return False
return True
@property
def data(self):
return self.launch_context.data
@property
def application(self):
return getattr(self.launch_context, "application", None)
@property
def manager(self):
return getattr(self.application, "manager", None)
@property
def host_name(self):
return getattr(self.application, "host_name", None)
@property
def app_group(self):
return getattr(self.application, "group", None)
@property
def app_name(self):
return getattr(self.application, "full_name", None)
@property
def addons_manager(self):
return getattr(self.launch_context, "addons_manager", None)
@property
def modules_manager(self):
"""
Deprecated:
Use 'addons_wrapper' instead.
"""
return self.addons_manager
def validate(self):
"""Optional validation of launch hook on initialization.
Returns:
bool: Hook is valid (True) or invalid (False).
"""
# QUESTION Not sure if this method has any usable potential.
# - maybe result can be based on settings
return True
@abstractmethod
def execute(self, *args, **kwargs):
"""Abstract execute method where logic of hook is."""
pass
class PreLaunchHook(LaunchHook):
"""Abstract class of prelaunch hook.
This launch hook will be processed before application is launched.
If any exception will happen during processing the application won't be
launched.
"""
class PostLaunchHook(LaunchHook):
"""Abstract class of postlaunch hook.
This launch hook will be processed after application is launched.
Nothing will happen if any exception will happen during processing. And
processing of other postlaunch hooks won't stop either.
"""

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

View file

@ -1,676 +0,0 @@
import os
import sys
import copy
import json
import tempfile
import platform
import inspect
import subprocess
import six
from ayon_core import AYON_CORE_ROOT
from ayon_core.settings import get_studio_settings
from ayon_core.lib import (
Logger,
modules_from_path,
classes_from_module,
get_linux_launcher_args,
)
from ayon_core.addon import AddonsManager
from .constants import DEFAULT_ENV_SUBGROUP
from .exceptions import (
ApplicationNotFound,
ApplicationExecutableNotFound,
)
from .hooks import PostLaunchHook, PreLaunchHook
from .defs import EnvironmentToolGroup, ApplicationGroup, LaunchTypes
class ApplicationManager:
"""Load applications and tools and store them by their full name.
Args:
studio_settings (dict): Preloaded studio settings. When passed manager
will always use these values. Gives ability to create manager
using different settings.
"""
def __init__(self, studio_settings=None):
self.log = Logger.get_logger(self.__class__.__name__)
self.app_groups = {}
self.applications = {}
self.tool_groups = {}
self.tools = {}
self._studio_settings = studio_settings
self.refresh()
def set_studio_settings(self, studio_settings):
"""Ability to change init system settings.
This will trigger refresh of manager.
"""
self._studio_settings = studio_settings
self.refresh()
def refresh(self):
"""Refresh applications from settings."""
self.app_groups.clear()
self.applications.clear()
self.tool_groups.clear()
self.tools.clear()
if self._studio_settings is not None:
settings = copy.deepcopy(self._studio_settings)
else:
settings = get_studio_settings(
clear_metadata=False, exclude_locals=False
)
applications_addon_settings = settings["applications"]
# Prepare known applications
app_defs = applications_addon_settings["applications"]
additional_apps = app_defs.pop("additional_apps")
for additional_app in additional_apps:
app_name = additional_app.pop("name")
if app_name in app_defs:
self.log.warning((
"Additional application '{}' is already"
" in built-in applications."
).format(app_name))
app_defs[app_name] = additional_app
for group_name, variant_defs in app_defs.items():
group = ApplicationGroup(group_name, variant_defs, self)
self.app_groups[group_name] = group
for app in group:
self.applications[app.full_name] = app
tools_definitions = applications_addon_settings["tool_groups"]
for tool_group_data in tools_definitions:
group = EnvironmentToolGroup(tool_group_data, self)
self.tool_groups[group.name] = group
for tool in group:
self.tools[tool.full_name] = tool
def find_latest_available_variant_for_group(self, group_name):
group = self.app_groups.get(group_name)
if group is None or not group.enabled:
return None
output = None
for _, variant in reversed(sorted(group.variants.items())):
executable = variant.find_executable()
if executable:
output = variant
break
return output
def create_launch_context(self, app_name, **data):
"""Prepare launch context for application.
Args:
app_name (str): Name of application that should be launched.
**data (Any): Any additional data. Data may be used during
Returns:
ApplicationLaunchContext: Launch context for application.
Raises:
ApplicationNotFound: Application was not found by entered name.
"""
app = self.applications.get(app_name)
if not app:
raise ApplicationNotFound(app_name)
executable = app.find_executable()
return ApplicationLaunchContext(
app, executable, **data
)
def launch_with_context(self, launch_context):
"""Launch application using existing launch context.
Args:
launch_context (ApplicationLaunchContext): Prepared launch
context.
"""
if not launch_context.executable:
raise ApplicationExecutableNotFound(launch_context.application)
return launch_context.launch()
def launch(self, app_name, **data):
"""Launch procedure.
For host application it's expected to contain "project_name",
"folder_path" and "task_name".
Args:
app_name (str): Name of application that should be launched.
**data (Any): Any additional data. Data may be used during
preparation to store objects usable in multiple places.
Raises:
ApplicationNotFound: Application was not found by entered
argument `app_name`.
ApplicationExecutableNotFound: Executables in application definition
were not found on this machine.
ApplicationLaunchFailed: Something important for application launch
failed. Exception should contain explanation message,
traceback should not be needed.
"""
context = self.create_launch_context(app_name, **data)
return self.launch_with_context(context)
class ApplicationLaunchContext:
"""Context of launching application.
Main purpose of context is to prepare launch arguments and keyword
arguments for new process. Most important part of keyword arguments
preparations are environment variables.
During the whole process is possible to use `data` attribute to store
object usable in multiple places.
Launch arguments are strings in list. It is possible to "chain" argument
when order of them matters. That is possible to do with adding list where
order is right and should not change.
NOTE: This is recommendation, not requirement.
e.g.: `["nuke.exe", "--NukeX"]` -> In this case any part of process may
insert argument between `nuke.exe` and `--NukeX`. To keep them together
it is better to wrap them in another list: `[["nuke.exe", "--NukeX"]]`.
Notes:
It is possible to use launch context only to prepare environment
variables. In that case `executable` may be None and can be used
'run_prelaunch_hooks' method to run prelaunch hooks which prepare
them.
Args:
application (Application): Application definition.
executable (ApplicationExecutable): Object with path to executable.
env_group (Optional[str]): Environment variable group. If not set
'DEFAULT_ENV_SUBGROUP' is used.
launch_type (Optional[str]): Launch type. If not set 'local' is used.
**data (dict): Any additional data. Data may be used during
preparation to store objects usable in multiple places.
"""
def __init__(
self,
application,
executable,
env_group=None,
launch_type=None,
**data
):
# Application object
self.application = application
self.addons_manager = AddonsManager()
# Logger
logger_name = "{}-{}".format(self.__class__.__name__,
self.application.full_name)
self.log = Logger.get_logger(logger_name)
self.executable = executable
if launch_type is None:
launch_type = LaunchTypes.local
self.launch_type = launch_type
if env_group is None:
env_group = DEFAULT_ENV_SUBGROUP
self.env_group = env_group
self.data = dict(data)
launch_args = []
if executable is not None:
launch_args = executable.as_args()
# subprocess.Popen launch arguments (first argument in constructor)
self.launch_args = launch_args
self.launch_args.extend(application.arguments)
if self.data.get("app_args"):
self.launch_args.extend(self.data.pop("app_args"))
# Handle launch environemtns
src_env = self.data.pop("env", None)
if src_env is not None and not isinstance(src_env, dict):
self.log.warning((
"Passed `env` kwarg has invalid type: {}. Expected: `dict`."
" Using `os.environ` instead."
).format(str(type(src_env))))
src_env = None
if src_env is None:
src_env = os.environ
ignored_env = {"QT_API", }
env = {
key: str(value)
for key, value in src_env.items()
if key not in ignored_env
}
# subprocess.Popen keyword arguments
self.kwargs = {"env": env}
if platform.system().lower() == "windows":
# Detach new process from currently running process on Windows
flags = (
subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.DETACHED_PROCESS
)
self.kwargs["creationflags"] = flags
if not sys.stdout:
self.kwargs["stdout"] = subprocess.DEVNULL
self.kwargs["stderr"] = subprocess.DEVNULL
self.prelaunch_hooks = None
self.postlaunch_hooks = None
self.process = None
self._prelaunch_hooks_executed = False
@property
def env(self):
if (
"env" not in self.kwargs
or self.kwargs["env"] is None
):
self.kwargs["env"] = {}
return self.kwargs["env"]
@env.setter
def env(self, value):
if not isinstance(value, dict):
raise ValueError(
"'env' attribute expect 'dict' object. Got: {}".format(
str(type(value))
)
)
self.kwargs["env"] = value
@property
def modules_manager(self):
"""
Deprecated:
Use 'addons_manager' instead.
"""
return self.addons_manager
def _collect_addons_launch_hook_paths(self):
"""Helper to collect application launch hooks from addons.
Module have to have implemented 'get_launch_hook_paths' method which
can expect application as argument or nothing.
Returns:
List[str]: Paths to launch hook directories.
"""
expected_types = (list, tuple, set)
output = []
for module in self.addons_manager.get_enabled_addons():
# Skip module if does not have implemented 'get_launch_hook_paths'
func = getattr(module, "get_launch_hook_paths", None)
if func is None:
continue
func = module.get_launch_hook_paths
if hasattr(inspect, "signature"):
sig = inspect.signature(func)
expect_args = len(sig.parameters) > 0
else:
expect_args = len(inspect.getargspec(func)[0]) > 0
# Pass application argument if method expect it.
try:
if expect_args:
hook_paths = func(self.application)
else:
hook_paths = func()
except Exception:
self.log.warning(
"Failed to call 'get_launch_hook_paths'",
exc_info=True
)
continue
if not hook_paths:
continue
# Convert string to list
if isinstance(hook_paths, six.string_types):
hook_paths = [hook_paths]
# Skip invalid types
if not isinstance(hook_paths, expected_types):
self.log.warning((
"Result of `get_launch_hook_paths`"
" has invalid type {}. Expected {}"
).format(type(hook_paths), expected_types))
continue
output.extend(hook_paths)
return output
def paths_to_launch_hooks(self):
"""Directory paths where to look for launch hooks."""
# This method has potential to be part of application manager (maybe).
paths = []
# TODO load additional studio paths from settings
global_hooks_dir = os.path.join(AYON_CORE_ROOT, "hooks")
hooks_dirs = [
global_hooks_dir
]
if self.host_name:
# If host requires launch hooks and is module then launch hooks
# should be collected using 'collect_launch_hook_paths'
# - module have to implement 'get_launch_hook_paths'
host_module = self.addons_manager.get_host_addon(self.host_name)
if not host_module:
hooks_dirs.append(os.path.join(
AYON_CORE_ROOT, "hosts", self.host_name, "hooks"
))
for path in hooks_dirs:
if (
os.path.exists(path)
and os.path.isdir(path)
and path not in paths
):
paths.append(path)
# Load modules paths
paths.extend(self._collect_addons_launch_hook_paths())
return paths
def discover_launch_hooks(self, force=False):
"""Load and prepare launch hooks."""
if (
self.prelaunch_hooks is not None
or self.postlaunch_hooks is not None
):
if not force:
self.log.info("Launch hooks were already discovered.")
return
self.prelaunch_hooks.clear()
self.postlaunch_hooks.clear()
self.log.debug("Discovery of launch hooks started.")
paths = self.paths_to_launch_hooks()
self.log.debug("Paths searched for launch hooks:\n{}".format(
"\n".join("- {}".format(path) for path in paths)
))
all_classes = {
"pre": [],
"post": []
}
for path in paths:
if not os.path.exists(path):
self.log.info(
"Path to launch hooks does not exist: \"{}\"".format(path)
)
continue
modules, _crashed = modules_from_path(path)
for _filepath, module in modules:
all_classes["pre"].extend(
classes_from_module(PreLaunchHook, module)
)
all_classes["post"].extend(
classes_from_module(PostLaunchHook, module)
)
for launch_type, classes in all_classes.items():
hooks_with_order = []
hooks_without_order = []
for klass in classes:
try:
hook = klass(self)
if not hook.is_valid:
self.log.debug(
"Skipped hook invalid for current launch context: "
"{}".format(klass.__name__)
)
continue
if inspect.isabstract(hook):
self.log.debug("Skipped abstract hook: {}".format(
klass.__name__
))
continue
# Separate hooks by pre/post class
if hook.order is None:
hooks_without_order.append(hook)
else:
hooks_with_order.append(hook)
except Exception:
self.log.warning(
"Initialization of hook failed: "
"{}".format(klass.__name__),
exc_info=True
)
# Sort hooks with order by order
ordered_hooks = list(sorted(
hooks_with_order, key=lambda obj: obj.order
))
# Extend ordered hooks with hooks without defined order
ordered_hooks.extend(hooks_without_order)
if launch_type == "pre":
self.prelaunch_hooks = ordered_hooks
else:
self.postlaunch_hooks = ordered_hooks
self.log.debug("Found {} prelaunch and {} postlaunch hooks.".format(
len(self.prelaunch_hooks), len(self.postlaunch_hooks)
))
@property
def app_name(self):
return self.application.name
@property
def host_name(self):
return self.application.host_name
@property
def app_group(self):
return self.application.group
@property
def manager(self):
return self.application.manager
def _run_process(self):
# Windows and MacOS have easier process start
low_platform = platform.system().lower()
if low_platform in ("windows", "darwin"):
return subprocess.Popen(self.launch_args, **self.kwargs)
# Linux uses mid process
# - it is possible that the mid process executable is not
# available for this version of AYON in that case use standard
# launch
launch_args = get_linux_launcher_args()
if launch_args is None:
return subprocess.Popen(self.launch_args, **self.kwargs)
# Prepare data that will be passed to midprocess
# - store arguments to a json and pass path to json as last argument
# - pass environments to set
app_env = self.kwargs.pop("env", {})
json_data = {
"args": self.launch_args,
"env": app_env
}
if app_env:
# Filter environments of subprocess
self.kwargs["env"] = {
key: value
for key, value in os.environ.items()
if key in app_env
}
# Create temp file
json_temp = tempfile.NamedTemporaryFile(
mode="w", prefix="op_app_args", suffix=".json", delete=False
)
json_temp.close()
json_temp_filpath = json_temp.name
with open(json_temp_filpath, "w") as stream:
json.dump(json_data, stream)
launch_args.append(json_temp_filpath)
# Create mid-process which will launch application
process = subprocess.Popen(launch_args, **self.kwargs)
# Wait until the process finishes
# - This is important! The process would stay in "open" state.
process.wait()
# Remove the temp file
os.remove(json_temp_filpath)
# Return process which is already terminated
return process
def run_prelaunch_hooks(self):
"""Run prelaunch hooks.
This method will be executed only once, any future calls will skip
the processing.
"""
if self._prelaunch_hooks_executed:
self.log.warning("Prelaunch hooks were already executed.")
return
# Discover launch hooks
self.discover_launch_hooks()
# Execute prelaunch hooks
for prelaunch_hook in self.prelaunch_hooks:
self.log.debug("Executing prelaunch hook: {}".format(
str(prelaunch_hook.__class__.__name__)
))
prelaunch_hook.execute()
self._prelaunch_hooks_executed = True
def launch(self):
"""Collect data for new process and then create it.
This method must not be executed more than once.
Returns:
subprocess.Popen: Created process as Popen object.
"""
if self.process is not None:
self.log.warning("Application was already launched.")
return
if not self._prelaunch_hooks_executed:
self.run_prelaunch_hooks()
self.log.debug("All prelaunch hook executed. Starting new process.")
# Prepare subprocess args
args_len_str = ""
if isinstance(self.launch_args, str):
args = self.launch_args
else:
args = self.clear_launch_args(self.launch_args)
args_len_str = " ({})".format(len(args))
self.log.info(
"Launching \"{}\" with args{}: {}".format(
self.application.full_name, args_len_str, args
)
)
self.launch_args = args
# Run process
self.process = self._run_process()
# Process post launch hooks
for postlaunch_hook in self.postlaunch_hooks:
self.log.debug("Executing postlaunch hook: {}".format(
str(postlaunch_hook.__class__.__name__)
))
# TODO how to handle errors?
# - store to variable to let them accessible?
try:
postlaunch_hook.execute()
except Exception:
self.log.warning(
"After launch procedures were not successful.",
exc_info=True
)
self.log.debug("Launch of {} finished.".format(
self.application.full_name
))
return self.process
@staticmethod
def clear_launch_args(args):
"""Collect launch arguments to final order.
Launch argument should be list that may contain another lists this
function will upack inner lists and keep ordering.
```
# source
[ [ arg1, [ arg2, arg3 ] ], arg4, [arg5, arg6]]
# result
[ arg1, arg2, arg3, arg4, arg5, arg6]
Args:
args (list): Source arguments in list may contain inner lists.
Return:
list: Unpacked arguments.
"""
if isinstance(args, str):
return args
all_cleared = False
while not all_cleared:
all_cleared = True
new_args = []
for arg in args:
if isinstance(arg, (list, tuple, set)):
all_cleared = False
for _arg in arg:
new_args.append(_arg)
else:
new_args.append(arg)
args = new_args
return args

View file

@ -1,48 +0,0 @@
"""
Run after global plugin 'CollectHostName' in ayon_core.
Requires:
None
Provides:
context -> hostName (str)
context -> appName (str)
context -> appLabel (str)
"""
import os
import pyblish.api
from ayon_applications import ApplicationManager
class CollectAppName(pyblish.api.ContextPlugin):
"""Collect avalon host name to context."""
label = "Collect App Name"
order = pyblish.api.CollectorOrder - 0.499999
def process(self, context):
host_name = context.data.get("hostName")
app_name = context.data.get("appName")
app_label = context.data.get("appLabel")
# Don't override value if is already set
if host_name and app_name and app_label:
return
# Use AYON_APP_NAME to get full app name
if not app_name:
app_name = os.environ.get("AYON_APP_NAME")
# Fill missing values based on app full name
if (not host_name or not app_label) and app_name:
app_manager = ApplicationManager()
app = app_manager.applications.get(app_name)
if app:
if not host_name:
host_name = app.host_name
if not app_label:
app_label = app.full_label
context.data["hostName"] = host_name
context.data["appName"] = app_name
context.data["appLabel"] = app_label

View file

@ -1,614 +0,0 @@
import os
import copy
import json
import platform
import collections
import six
import acre
from ayon_core import AYON_CORE_ROOT
from ayon_core.settings import get_project_settings
from ayon_core.lib import Logger, get_ayon_username
from ayon_core.addon import AddonsManager
from ayon_core.pipeline.template_data import get_template_data
from ayon_core.pipeline.workfile import (
get_workfile_template_key,
get_workdir_with_workdir_data,
get_last_workfile,
should_use_last_workfile_on_launch,
should_open_workfiles_tool_on_launch,
)
from .constants import PLATFORM_NAMES, DEFAULT_ENV_SUBGROUP
from .exceptions import MissingRequiredKey, ApplicationLaunchFailed
from .manager import ApplicationManager
def parse_environments(env_data, env_group=None, platform_name=None):
"""Parse environment values from settings byt group and platform.
Data may contain up to 2 hierarchical levels of dictionaries. At the end
of the last level must be string or list. List is joined using platform
specific joiner (';' for windows and ':' for linux and mac).
Hierarchical levels can contain keys for subgroups and platform name.
Platform specific values must be always last level of dictionary. Platform
names are "windows" (MS Windows), "linux" (any linux distribution) and
"darwin" (any MacOS distribution).
Subgroups are helpers added mainly for standard and on farm usage. Farm
may require different environments for e.g. licence related values or
plugins. Default subgroup is "standard".
Examples:
```
{
# Unchanged value
"ENV_KEY1": "value",
# Empty values are kept (unset environment variable)
"ENV_KEY2": "",
# Join list values with ':' or ';'
"ENV_KEY3": ["value1", "value2"],
# Environment groups
"ENV_KEY4": {
"standard": "DEMO_SERVER_URL",
"farm": "LICENCE_SERVER_URL"
},
# Platform specific (and only for windows and mac)
"ENV_KEY5": {
"windows": "windows value",
"darwin": ["value 1", "value 2"]
},
# Environment groups and platform combination
"ENV_KEY6": {
"farm": "FARM_VALUE",
"standard": {
"windows": ["value1", "value2"],
"linux": "value1",
"darwin": ""
}
}
}
```
"""
output = {}
if not env_data:
return output
if not env_group:
env_group = DEFAULT_ENV_SUBGROUP
if not platform_name:
platform_name = platform.system().lower()
for key, value in env_data.items():
if isinstance(value, dict):
# Look if any key is platform key
# - expect that represents environment group if does not contain
# platform keys
if not PLATFORM_NAMES.intersection(set(value.keys())):
# Skip the key if group is not available
if env_group not in value:
continue
value = value[env_group]
# Check again if value is dictionary
# - this time there should be only platform keys
if isinstance(value, dict):
value = value.get(platform_name)
# Check if value is list and join it's values
# QUESTION Should empty values be skipped?
if isinstance(value, (list, tuple)):
value = os.pathsep.join(value)
# Set key to output if value is string
if isinstance(value, six.string_types):
output[key] = value
return output
class EnvironmentPrepData(dict):
"""Helper dictionary for storin temp data during environment prep.
Args:
data (dict): Data must contain required keys.
"""
required_keys = (
"project_entity", "folder_entity", "task_entity", "app", "anatomy"
)
def __init__(self, data):
for key in self.required_keys:
if key not in data:
raise MissingRequiredKey(key)
if not data.get("log"):
data["log"] = Logger.get_logger("EnvironmentPrepData")
if data.get("env") is None:
data["env"] = os.environ.copy()
project_name = data["project_entity"]["name"]
if "project_settings" not in data:
data["project_settings"] = get_project_settings(project_name)
super(EnvironmentPrepData, self).__init__(data)
def get_app_environments_for_context(
project_name,
folder_path,
task_name,
app_name,
env_group=None,
launch_type=None,
env=None,
addons_manager=None
):
"""Prepare environment variables by context.
Args:
project_name (str): Name of project.
folder_path (str): Folder path.
task_name (str): Name of task.
app_name (str): Name of application that is launched and can be found
by ApplicationManager.
env_group (Optional[str]): Name of environment group. If not passed
default group is used.
launch_type (Optional[str]): Type for which prelaunch hooks are
executed.
env (Optional[dict[str, str]]): Initial environment variables.
`os.environ` is used when not passed.
addons_manager (Optional[AddonsManager]): Initialized modules
manager.
Returns:
dict: Environments for passed context and application.
"""
# Prepare app object which can be obtained only from ApplicationManager
app_manager = ApplicationManager()
context = app_manager.create_launch_context(
app_name,
project_name=project_name,
folder_path=folder_path,
task_name=task_name,
env_group=env_group,
launch_type=launch_type,
env=env,
addons_manager=addons_manager,
modules_manager=addons_manager,
)
context.run_prelaunch_hooks()
return context.env
def _merge_env(env, current_env):
"""Modified function(merge) from acre module."""
result = current_env.copy()
for key, value in env.items():
# Keep missing keys by not filling `missing` kwarg
value = acre.lib.partial_format(value, data=current_env)
result[key] = value
return result
def _add_python_version_paths(app, env, logger, addons_manager):
"""Add vendor packages specific for a Python version."""
for addon in addons_manager.get_enabled_addons():
addon.modify_application_launch_arguments(app, env)
# Skip adding if host name is not set
if not app.host_name:
return
# Add Python 2/3 modules
python_vendor_dir = os.path.join(
AYON_CORE_ROOT,
"vendor",
"python"
)
if app.use_python_2:
pythonpath = os.path.join(python_vendor_dir, "python_2")
else:
pythonpath = os.path.join(python_vendor_dir, "python_3")
if not os.path.exists(pythonpath):
return
logger.debug("Adding Python version specific paths to PYTHONPATH")
python_paths = [pythonpath]
# Load PYTHONPATH from current launch context
python_path = env.get("PYTHONPATH")
if python_path:
python_paths.append(python_path)
# Set new PYTHONPATH to launch context environments
env["PYTHONPATH"] = os.pathsep.join(python_paths)
def prepare_app_environments(
data, env_group=None, implementation_envs=True, addons_manager=None
):
"""Modify launch environments based on launched app and context.
Args:
data (EnvironmentPrepData): Dictionary where result and intermediate
result will be stored.
"""
app = data["app"]
log = data["log"]
source_env = data["env"].copy()
if addons_manager is None:
addons_manager = AddonsManager()
_add_python_version_paths(app, source_env, log, addons_manager)
# Use environments from local settings
filtered_local_envs = {}
# NOTE Overrides for environment variables are not implemented in AYON.
# project_settings = data["project_settings"]
# whitelist_envs = project_settings["general"].get("local_env_white_list")
# if whitelist_envs:
# local_settings = get_local_settings()
# local_envs = local_settings.get("environments") or {}
# filtered_local_envs = {
# key: value
# for key, value in local_envs.items()
# if key in whitelist_envs
# }
# Apply local environment variables for already existing values
for key, value in filtered_local_envs.items():
if key in source_env:
source_env[key] = value
# `app_and_tool_labels` has debug purpose
app_and_tool_labels = [app.full_name]
# Environments for application
environments = [
app.group.environment,
app.environment
]
task_entity = data.get("task_entity")
folder_entity = data.get("folder_entity")
# Add tools environments
groups_by_name = {}
tool_by_group_name = collections.defaultdict(dict)
tools = None
if task_entity:
tools = task_entity["attrib"].get("tools")
if tools is None and folder_entity:
tools = folder_entity["attrib"].get("tools")
if tools:
for key in tools:
tool = app.manager.tools.get(key)
if not tool or not tool.is_valid_for_app(app):
continue
groups_by_name[tool.group.name] = tool.group
tool_by_group_name[tool.group.name][tool.name] = tool
for group_name in sorted(groups_by_name.keys()):
group = groups_by_name[group_name]
environments.append(group.environment)
for tool_name in sorted(tool_by_group_name[group_name].keys()):
tool = tool_by_group_name[group_name][tool_name]
environments.append(tool.environment)
app_and_tool_labels.append(tool.full_name)
log.debug(
"Will add environments for apps and tools: {}".format(
", ".join(app_and_tool_labels)
)
)
env_values = {}
for _env_values in environments:
if not _env_values:
continue
# Choose right platform
tool_env = parse_environments(_env_values, env_group)
# Apply local environment variables
# - must happen between all values because they may be used during
# merge
for key, value in filtered_local_envs.items():
if key in tool_env:
tool_env[key] = value
# Merge dictionaries
env_values = _merge_env(tool_env, env_values)
merged_env = _merge_env(env_values, source_env)
loaded_env = acre.compute(merged_env, cleanup=False)
final_env = None
# Add host specific environments
if app.host_name and implementation_envs:
host_addon = addons_manager.get_host_addon(app.host_name)
add_implementation_envs = None
if host_addon:
add_implementation_envs = getattr(
host_addon, "add_implementation_envs", None
)
if add_implementation_envs:
# Function may only modify passed dict without returning value
final_env = add_implementation_envs(loaded_env, app)
if final_env is None:
final_env = loaded_env
keys_to_remove = set(source_env.keys()) - set(final_env.keys())
# Update env
data["env"].update(final_env)
for key in keys_to_remove:
data["env"].pop(key, None)
def apply_project_environments_value(
project_name, env, project_settings=None, env_group=None
):
"""Apply project specific environments on passed environments.
The environments are applied on passed `env` argument value so it is not
required to apply changes back.
Args:
project_name (str): Name of project for which environments should be
received.
env (dict): Environment values on which project specific environments
will be applied.
project_settings (dict): Project settings for passed project name.
Optional if project settings are already prepared.
Returns:
dict: Passed env values with applied project environments.
Raises:
KeyError: If project settings do not contain keys for project specific
environments.
"""
if project_settings is None:
project_settings = get_project_settings(project_name)
env_value = project_settings["core"]["project_environments"]
if env_value:
env_value = json.loads(env_value)
parsed_value = parse_environments(env_value, env_group)
env.update(acre.compute(
_merge_env(parsed_value, env),
cleanup=False
))
return env
def prepare_context_environments(data, env_group=None, addons_manager=None):
"""Modify launch environments with context data for launched host.
Args:
data (EnvironmentPrepData): Dictionary where result and intermediate
result will be stored.
"""
# Context environments
log = data["log"]
project_entity = data["project_entity"]
folder_entity = data["folder_entity"]
task_entity = data["task_entity"]
if not project_entity:
log.info(
"Skipping context environments preparation."
" Launch context does not contain required data."
)
return
# Load project specific environments
project_name = project_entity["name"]
project_settings = get_project_settings(project_name)
data["project_settings"] = project_settings
app = data["app"]
context_env = {
"AYON_PROJECT_NAME": project_entity["name"],
"AYON_APP_NAME": app.full_name
}
if folder_entity:
folder_path = folder_entity["path"]
context_env["AYON_FOLDER_PATH"] = folder_path
if task_entity:
context_env["AYON_TASK_NAME"] = task_entity["name"]
log.debug(
"Context environments set:\n{}".format(
json.dumps(context_env, indent=4)
)
)
data["env"].update(context_env)
# Apply project specific environments on current env value
# - apply them once the context environments are set
apply_project_environments_value(
project_name, data["env"], project_settings, env_group
)
if not app.is_host:
return
data["env"]["AYON_HOST_NAME"] = app.host_name
if not folder_entity or not task_entity:
# QUESTION replace with log.info and skip workfile discovery?
# - technically it should be possible to launch host without context
raise ApplicationLaunchFailed(
"Host launch require folder and task context."
)
workdir_data = get_template_data(
project_entity,
folder_entity,
task_entity,
app.host_name,
project_settings
)
data["workdir_data"] = workdir_data
anatomy = data["anatomy"]
task_type = workdir_data["task"]["type"]
# Temp solution how to pass task type to `_prepare_last_workfile`
data["task_type"] = task_type
try:
workdir = get_workdir_with_workdir_data(
workdir_data,
anatomy.project_name,
anatomy,
project_settings=project_settings
)
except Exception as exc:
raise ApplicationLaunchFailed(
"Error in anatomy.format: {}".format(str(exc))
)
if not os.path.exists(workdir):
log.debug(
"Creating workdir folder: \"{}\"".format(workdir)
)
try:
os.makedirs(workdir)
except Exception as exc:
raise ApplicationLaunchFailed(
"Couldn't create workdir because: {}".format(str(exc))
)
data["env"]["AYON_WORKDIR"] = workdir
_prepare_last_workfile(data, workdir, addons_manager)
def _prepare_last_workfile(data, workdir, addons_manager):
"""last workfile workflow preparation.
Function check if should care about last workfile workflow and tries
to find the last workfile. Both information are stored to `data` and
environments.
Last workfile is filled always (with version 1) even if any workfile
exists yet.
Args:
data (EnvironmentPrepData): Dictionary where result and intermediate
result will be stored.
workdir (str): Path to folder where workfiles should be stored.
"""
if not addons_manager:
addons_manager = AddonsManager()
log = data["log"]
_workdir_data = data.get("workdir_data")
if not _workdir_data:
log.info(
"Skipping last workfile preparation."
" Key `workdir_data` not filled."
)
return
app = data["app"]
workdir_data = copy.deepcopy(_workdir_data)
project_name = data["project_name"]
task_name = data["task_name"]
task_type = data["task_type"]
start_last_workfile = data.get("start_last_workfile")
if start_last_workfile is None:
start_last_workfile = should_use_last_workfile_on_launch(
project_name, app.host_name, task_name, task_type
)
else:
log.info("Opening of last workfile was disabled by user")
data["start_last_workfile"] = start_last_workfile
workfile_startup = should_open_workfiles_tool_on_launch(
project_name, app.host_name, task_name, task_type
)
data["workfile_startup"] = workfile_startup
# Store boolean as "0"(False) or "1"(True)
data["env"]["AVALON_OPEN_LAST_WORKFILE"] = (
str(int(bool(start_last_workfile)))
)
data["env"]["AYON_WORKFILE_TOOL_ON_START"] = (
str(int(bool(workfile_startup)))
)
_sub_msg = "" if start_last_workfile else " not"
log.debug(
"Last workfile should{} be opened on start.".format(_sub_msg)
)
# Last workfile path
last_workfile_path = data.get("last_workfile_path") or ""
if not last_workfile_path:
host_addon = addons_manager.get_host_addon(app.host_name)
extensions = None
if host_addon:
extensions = host_addon.get_workfile_extensions()
if extensions:
anatomy = data["anatomy"]
project_settings = data["project_settings"]
task_type = workdir_data["task"]["type"]
template_key = get_workfile_template_key(
project_name,
task_type,
app.host_name,
project_settings=project_settings
)
# Find last workfile
file_template = anatomy.get_template_item(
"work", template_key, "file"
).template
workdir_data.update({
"version": 1,
"user": get_ayon_username(),
"ext": extensions[0]
})
last_workfile_path = get_last_workfile(
workdir, file_template, workdir_data, extensions, True
)
if os.path.exists(last_workfile_path):
log.debug((
"Workfiles for launch context does not exists"
" yet but path will be set."
))
log.debug(
"Setting last workfile path: {}".format(last_workfile_path)
)
data["env"]["AYON_LAST_WORKFILE"] = last_workfile_path
data["last_workfile_path"] = last_workfile_path

View file

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

View file

@ -1,12 +0,0 @@
name = "applications"
title = "Applications"
version = "0.2.4"
client_dir = "ayon_applications"
ayon_server_version = ">=1.0.7"
ayon_launcher_version = ">=1.0.2"
ayon_required_addons = {
"core": ">0.3.0",
}
ayon_compatible_addons = {}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

View file

@ -1,311 +0,0 @@
import os
import json
import copy
from ayon_server.addons import BaseServerAddon, AddonLibrary
from ayon_server.entities.core import attribute_library
from ayon_server.lib.postgres import Postgres
from .settings import ApplicationsAddonSettings, DEFAULT_VALUES
try:
import semver
except ImportError:
semver = None
def sort_versions(addon_versions, reverse=False):
if semver is None:
for addon_version in sorted(addon_versions, reverse=reverse):
yield addon_version
return
version_objs = []
invalid_versions = []
for addon_version in addon_versions:
try:
version_objs.append(
(addon_version, semver.VersionInfo.parse(addon_version))
)
except ValueError:
invalid_versions.append(addon_version)
valid_versions = [
addon_version
for addon_version, _ in sorted(version_objs, key=lambda x: x[1])
]
sorted_versions = list(sorted(invalid_versions)) + valid_versions
if reverse:
sorted_versions = reversed(sorted_versions)
for addon_version in sorted_versions:
yield addon_version
def merge_groups(output, new_groups):
groups_by_name = {
o_group["name"]: o_group
for o_group in output
}
extend_groups = []
for new_group in new_groups:
group_name = new_group["name"]
if group_name not in groups_by_name:
extend_groups.append(new_group)
continue
existing_group = groups_by_name[group_name]
existing_variants = existing_group["variants"]
existing_variants_by_name = {
variant["name"]: variant
for variant in existing_variants
}
for new_variant in new_group["variants"]:
if new_variant["name"] not in existing_variants_by_name:
existing_variants.append(new_variant)
output.extend(extend_groups)
def get_enum_items_from_groups(groups):
label_by_name = {}
for group in groups:
group_name = group["name"]
group_label = group["label"] or group_name
for variant in group["variants"]:
variant_name = variant["name"]
if not variant_name:
continue
variant_label = variant["label"] or variant_name
full_name = f"{group_name}/{variant_name}"
full_label = f"{group_label} {variant_label}"
label_by_name[full_name] = full_label
return [
{"value": full_name, "label": label_by_name[full_name]}
for full_name in sorted(label_by_name)
]
class ApplicationsAddon(BaseServerAddon):
settings_model = ApplicationsAddonSettings
async def get_default_settings(self):
server_dir = os.path.join(self.addon_dir, "server")
applications_path = os.path.join(server_dir, "applications.json")
tools_path = os.path.join(server_dir, "tools.json")
default_values = copy.deepcopy(DEFAULT_VALUES)
with open(applications_path, "r") as stream:
default_values.update(json.load(stream))
with open(tools_path, "r") as stream:
default_values.update(json.load(stream))
return self.get_settings_model()(**default_values)
async def pre_setup(self):
"""Make sure older version of addon use the new way of attributes."""
instance = AddonLibrary.getinstance()
app_defs = instance.data.get(self.name)
old_addon = app_defs.versions.get("0.1.0")
if old_addon is not None:
# Override 'create_applications_attribute' for older versions
# - avoid infinite server restart loop
old_addon.create_applications_attribute = (
self.create_applications_attribute
)
async def setup(self):
need_restart = await self.create_required_attributes()
if need_restart:
self.request_server_restart()
await self._update_enums()
def _get_applications_def(self):
return {
"name": "applications",
"type": "list_of_strings",
"title": "Applications",
"scope": ["project"],
"enum":[],
}
def _get_tools_def(self):
return {
"name": "tools",
"type": "list_of_strings",
"title": "Tools",
"scope": ["project", "folder", "task"],
"enum":[],
}
async def create_applications_attribute(self) -> bool:
"""Make sure there are required attributes which ftrack addon needs.
Returns:
bool: 'True' if an attribute was created or updated.
"""
need_restart = await self.create_required_attributes()
await self._update_enums()
return need_restart
async def create_required_attributes(self) -> bool:
"""Make sure there are required 'applications' and 'tools' attributes.
This only checks for the existence of the attributes, it does not populate
them with any data. When an attribute is added, server needs to be restarted,
while adding enum data to the attribute does not require a restart.
Returns:
bool: 'True' if an attribute was created or updated.
"""
# keep track of the last attribute position (for adding new attributes)
apps_attribute_data = self._get_applications_def()
tools_attribute_data = self._get_tools_def()
apps_attrib_name = apps_attribute_data["name"]
tools_attrib_name = tools_attribute_data["name"]
async with Postgres.acquire() as conn, conn.transaction():
query = "SELECT BOOL_OR(name = 'applications') AS has_applications, BOOL_OR(name = 'tools') AS has_tools FROM attributes;"
result = (await conn.fetch(query))[0]
attributes_to_create = {}
if not result["has_applications"]:
attributes_to_create[apps_attrib_name] = {
"scope": apps_attribute_data["scope"],
"data": {
"title": apps_attribute_data["title"],
"type": apps_attribute_data["type"],
"enum": [],
}
}
if not result["has_tools"]:
attributes_to_create[tools_attrib_name] = {
"scope": tools_attribute_data["scope"],
"data": {
"title": tools_attribute_data["title"],
"type": tools_attribute_data["type"],
"enum": [],
},
}
needs_restart = False
# when any of the required attributes are not present, add them
# and return 'True' to indicate that server needs to be restarted
for name, payload in attributes_to_create.items():
insert_query = "INSERT INTO attributes (name, scope, data, position) VALUES ($1, $2, $3, (SELECT COALESCE(MAX(position), 0) + 1 FROM attributes)) ON CONFLICT DO NOTHING"
await conn.execute(
insert_query,
name,
payload["scope"],
payload["data"],
)
needs_restart = True
return needs_restart
async def _update_enums(self):
"""Updates applications and tools enums based on the addon settings.
This method is called when the addon is started (after we are sure that the
'applications' and 'tools' attributes exist) and when the addon settings are
updated (using on_settings_updated method).
"""
instance = AddonLibrary.getinstance()
app_defs = instance.data.get(self.name)
all_applications = []
all_tools = []
for addon_version in sort_versions(
app_defs.versions.keys(), reverse=True
):
addon = app_defs.versions[addon_version]
for variant in ("production", "staging"):
settings_model = await addon.get_studio_settings(variant)
studio_settings = settings_model.dict()
application_settings = studio_settings["applications"]
app_groups = application_settings.pop("additional_apps")
for group_name, value in application_settings.items():
value["name"] = group_name
app_groups.append(value)
merge_groups(all_applications, app_groups)
merge_groups(all_tools, studio_settings["tool_groups"])
apps_attrib_name = "applications"
tools_attrib_name = "tools"
apps_enum = get_enum_items_from_groups(all_applications)
tools_enum = get_enum_items_from_groups(all_tools)
apps_attribute_data = {
"type": "list_of_strings",
"title": "Applications",
"enum": apps_enum,
}
tools_attribute_data = {
"type": "list_of_strings",
"title": "Tools",
"enum": tools_enum,
}
apps_scope = ["project"]
tools_scope = ["project", "folder", "task"]
apps_matches = False
tools_matches = False
async for row in Postgres.iterate(
"SELECT name, position, scope, data from public.attributes"
):
if row["name"] == apps_attrib_name:
# Check if scope is matching ftrack addon requirements
if (
set(row["scope"]) == set(apps_scope)
and row["data"].get("enum") == apps_enum
):
apps_matches = True
elif row["name"] == tools_attrib_name:
if (
set(row["scope"]) == set(tools_scope)
and row["data"].get("enum") == tools_enum
):
tools_matches = True
if apps_matches and tools_matches:
return
if not apps_matches:
await Postgres.execute(
"""
UPDATE attributes SET
scope = $1,
data = $2
WHERE
name = $3
""",
apps_scope,
apps_attribute_data,
apps_attrib_name,
)
if not tools_matches:
await Postgres.execute(
"""
UPDATE attributes SET
scope = $1,
data = $2
WHERE
name = $3
""",
tools_scope,
tools_attribute_data,
tools_attrib_name,
)
# Reset attributes cache on server
await attribute_library.load()
async def on_settings_changed(self, *args, **kwargs):
_ = args, kwargs
await self._update_enums()

File diff suppressed because it is too large Load diff

View file

@ -1,233 +0,0 @@
import json
from pydantic import validator
from ayon_server.settings import (
BaseSettingsModel,
SettingsField,
ensure_unique_names,
)
from ayon_server.exceptions import BadRequestException
def validate_json_dict(value):
if not value.strip():
return "{}"
try:
converted_value = json.loads(value)
success = isinstance(converted_value, dict)
except json.JSONDecodeError as exc:
print(exc)
success = False
if not success:
raise BadRequestException(
"Environment's can't be parsed as json object"
)
return value
class MultiplatformStrList(BaseSettingsModel):
windows: list[str] = SettingsField(default_factory=list, title="Windows")
linux: list[str] = SettingsField(default_factory=list, title="Linux")
darwin: list[str] = SettingsField(default_factory=list, title="MacOS")
class AppVariant(BaseSettingsModel):
name: str = SettingsField("", title="Name")
label: str = SettingsField("", title="Label")
executables: MultiplatformStrList = SettingsField(
default_factory=MultiplatformStrList, title="Executables"
)
arguments: MultiplatformStrList = SettingsField(
default_factory=MultiplatformStrList, title="Arguments"
)
environment: str = SettingsField(
"{}", title="Environment", widget="textarea"
)
@validator("environment")
def validate_json(cls, value):
return validate_json_dict(value)
class AppVariantWithPython(AppVariant):
use_python_2: bool = SettingsField(False, title="Use Python 2")
class AppGroup(BaseSettingsModel):
enabled: bool = SettingsField(True)
label: str = SettingsField("", title="Label")
host_name: str = SettingsField("", title="Host name")
icon: str = SettingsField("", title="Icon")
environment: str = SettingsField(
"{}", title="Environment", widget="textarea"
)
variants: list[AppVariant] = SettingsField(
default_factory=list,
title="Variants",
description="Different variants of the applications",
section="Variants",
)
@validator("variants")
def validate_unique_name(cls, value):
ensure_unique_names(value)
return value
class AppGroupWithPython(AppGroup):
variants: list[AppVariantWithPython] = SettingsField(
default_factory=list,
title="Variants",
description="Different variants of the applications",
section="Variants",
)
class AdditionalAppGroup(BaseSettingsModel):
enabled: bool = SettingsField(True)
name: str = SettingsField("", title="Name")
label: str = SettingsField("", title="Label")
host_name: str = SettingsField("", title="Host name")
icon: str = SettingsField("", title="Icon")
environment: str = SettingsField(
"{}", title="Environment", widget="textarea"
)
variants: list[AppVariantWithPython] = SettingsField(
default_factory=list,
title="Variants",
description="Different variants of the applications",
section="Variants",
)
@validator("variants")
def validate_unique_name(cls, value):
ensure_unique_names(value)
return value
class ToolVariantModel(BaseSettingsModel):
name: str = SettingsField("", title="Name")
label: str = SettingsField("", title="Label")
host_names: list[str] = SettingsField(default_factory=list, title="Hosts")
# TODO use applications enum if possible
app_variants: list[str] = SettingsField(
default_factory=list, title="Applications"
)
environment: str = SettingsField(
"{}", title="Environments", widget="textarea"
)
@validator("environment")
def validate_json(cls, value):
return validate_json_dict(value)
class ToolGroupModel(BaseSettingsModel):
name: str = SettingsField("", title="Name")
label: str = SettingsField("", title="Label")
environment: str = SettingsField(
"{}", title="Environments", widget="textarea"
)
variants: list[ToolVariantModel] = SettingsField(default_factory=list)
@validator("environment")
def validate_json(cls, value):
return validate_json_dict(value)
@validator("variants")
def validate_unique_name(cls, value):
ensure_unique_names(value)
return value
class ApplicationsSettings(BaseSettingsModel):
"""Applications settings"""
maya: AppGroupWithPython = SettingsField(
default_factory=AppGroupWithPython, title="Autodesk Maya")
adsk_3dsmax: AppGroupWithPython = SettingsField(
default_factory=AppGroupWithPython, title="Autodesk 3ds Max")
flame: AppGroupWithPython = SettingsField(
default_factory=AppGroupWithPython, title="Autodesk Flame")
nuke: AppGroupWithPython = SettingsField(
default_factory=AppGroupWithPython, title="Nuke")
nukeassist: AppGroupWithPython = SettingsField(
default_factory=AppGroupWithPython, title="Nuke Assist")
nukex: AppGroupWithPython = SettingsField(
default_factory=AppGroupWithPython, title="Nuke X")
nukestudio: AppGroupWithPython = SettingsField(
default_factory=AppGroupWithPython, title="Nuke Studio")
hiero: AppGroupWithPython = SettingsField(
default_factory=AppGroupWithPython, title="Hiero")
fusion: AppGroup = SettingsField(
default_factory=AppGroupWithPython, title="Fusion")
resolve: AppGroupWithPython = SettingsField(
default_factory=AppGroupWithPython, title="Resolve")
houdini: AppGroupWithPython = SettingsField(
default_factory=AppGroupWithPython, title="Houdini")
blender: AppGroup = SettingsField(
default_factory=AppGroupWithPython, title="Blender")
harmony: AppGroup = SettingsField(
default_factory=AppGroupWithPython, title="Harmony")
tvpaint: AppGroup = SettingsField(
default_factory=AppGroupWithPython, title="TVPaint")
photoshop: AppGroup = SettingsField(
default_factory=AppGroupWithPython, title="Adobe Photoshop")
aftereffects: AppGroup = SettingsField(
default_factory=AppGroupWithPython, title="Adobe After Effects")
celaction: AppGroup = SettingsField(
default_factory=AppGroupWithPython, title="Celaction 2D")
substancepainter: AppGroup = SettingsField(
default_factory=AppGroupWithPython, title="Substance Painter")
unreal: AppGroup = SettingsField(
default_factory=AppGroupWithPython, title="Unreal Editor")
wrap: AppGroup = SettingsField(
default_factory=AppGroupWithPython, title="Wrap")
openrv: AppGroup = SettingsField(
default_factory=AppGroupWithPython, title="OpenRV")
zbrush: AppGroup = SettingsField(
default_factory=AppGroupWithPython, title="Zbrush")
equalizer: AppGroup = SettingsField(
default_factory=AppGroupWithPython, title="3DEqualizer")
motionbuilder: AppGroup = SettingsField(
default_factory=AppGroupWithPython, title="Motion Builder")
additional_apps: list[AdditionalAppGroup] = SettingsField(
default_factory=list, title="Additional Applications")
@validator("additional_apps")
def validate_unique_name(cls, value):
ensure_unique_names(value)
return value
class ApplicationsAddonSettings(BaseSettingsModel):
applications: ApplicationsSettings = SettingsField(
default_factory=ApplicationsSettings,
title="Applications",
scope=["studio"]
)
tool_groups: list[ToolGroupModel] = SettingsField(
default_factory=list,
scope=["studio"]
)
only_available: bool = SettingsField(
True,
title="Show only available applications",
description="Enable to show only applications in AYON Launcher"
" for which the executable paths are found on the running machine."
" This applies as an additional filter to the applications defined in a "
" project's anatomy settings to ignore unavailable applications."
)
@validator("tool_groups")
def validate_unique_name(cls, value):
ensure_unique_names(value)
return value
DEFAULT_VALUES = {
"only_available": True
}

View file

@ -1,149 +0,0 @@
{
"tool_groups": [
{
"name": "htoa",
"label": "Arnold for Houdini (example)",
"variants": [
{
"name": "5-4-2-7",
"label": "",
"host_names": [
"houdini"
],
"environment": "{\n \"HTOA_VERSION\": \"5.4.2.7\"\n}",
"app_variants": []
}
],
"environment": "{\n \"_comment_\": \"{STUDIO_SW} points to software repository. Can be defined in Core addon globally\",\n\n \"HOUDINI_PATH\": [\n \"{STUDIO_SW}/APP/HTOA/{HTOA_VERSION}/HOUDINI{HOUDINI_VERSION}/WINDOWS/htoa-6.1.3.3_rdb15014_houdini-{HTOA_VERSION}\",\n \"{HOUDINI_PATH}\"\n ],\n \"PATH\": {\n \"windows\": [\n \"{STUDIO_SW}/APP/HTOA/{HTOA_VERSION}/HOUDINI{HOUDINI_VERSION}/WINDOWS/htoa-6.1.3.3_rdb15014_houdini-{HTOA_VERSION}/scripts/bin\",\n \"{PATH}\"\n ]\n }\n}"
},
{
"name": "mtoa",
"label": "Arnold for Maya (example)",
"variants": [
{
"name": "5-3-1-0",
"label": "",
"host_names": [],
"environment": "{\n \"MTOA_VERSION\": \"5.3.1.0\"\n}",
"app_variants": []
},
{
"name": "5-3-4-1",
"label": "",
"host_names": [],
"environment": "{\n \"MTOA_VERSION\": \"5.3.4.1\"\n}",
"app_variants": []
}
],
"environment": "{\n \"_comment_\": \"{STUDIO_SW} points to software repository. Can be defined in Core addon globally\",\n\n \"MTOA\": {\n \"darwin\": \"{STUDIO_SW}/APP/MTOA/{MTOA_VERSION}/MAYA{MAYA_VERSION}/MAC\",\n \"linux\": \"{STUDIO_SW}/APP/MTOA/{MTOA_VERSION}/MAYA{MAYA_VERSION}/LINUX\",\n \"windows\": \"{STUDIO_SW}/APP/MTOA/{MTOA_VERSION}/MAYA{MAYA_VERSION}/WINDOWS\"\n },\n \"MAYA_MODULE_PATH\": [\n \"{STUDIO_SW}/APP/MTOA\",\n \"{MAYA_MODULE_PATH}\"\n ],\n \"DYLD_LIBRARY_PATH\": {\n \"darwin\": \"{MTOA}/bin\"\n },\n \"PATH\": {\n \"windows\": [\n \"{MTOA}/bin\",\n \"{PATH}\"\n ]\n },\n \"XBMLANGPATH\": [\n \"{MTOA}/icons\",\n \"{XBMLANGPATH}\"\n ],\n \"MAYA_RENDER_DESC_PATH\": [\n \"{MTOA}\",\n \"{MAYA_RENDER_DESC_PATH}\"\n ],\n \"MTOA_STARTUP_LOG_VERBOSITY\": \"3\"\n}"
},
{
"name": "redshiftMaya",
"label": "Redshift for Maya (example)",
"variants": [
{
"name": "3-5-23",
"label": "",
"host_names": [],
"environment": "{\n \"REDSHIFT_VERSION\": \"3.5.23\"\n}",
"app_variants": []
}
],
"environment": "{\n \"_comment_\": \"{STUDIO_SW} points to software repository. Can be defined in Core addon globally\",\n\n \"REDSHIFT_COREDATAPATH\": {\n \"darwin\": \"{STUDIO_SW}/APP/REDSHIFT/{REDSHIFT_VERSION}/MAC\",\n \"linux\": \"{STUDIO_SW}/APP/REDSHIFT/{REDSHIFT_VERSION}/LINUX\",\n \"windows\": \"{STUDIO_SW}/APP/REDSHIFT/{REDSHIFT_VERSION}/WINDOWS\"\n },\n \"REDSHIFT_ABORTONLICENSEFAIL\": \"0\",\n \"MAYA_MODULE_PATH\": [\n \"{STUDIO_SW}/APP/REDSHIFT\",\n \"{MAYA_MODULE_PATH}\"\n ],\n \"MAYA_PLUG_IN_PATH\": {\n \"windows\": [\n \"{REDSHIFT_COREDATAPATH}/Plugins/Maya/{MAYA_VERSION}/nt-x86-64\",\n \"{MAYA_PLUG_IN_PATH}\"\n ],\n \"linux\": [\n \"{REDSHIFT_COREDATAPATH}/redshift4maya/{MAYA_VERSION}\",\n \"{MAYA_PLUG_IN_PATH}\"\n ],\n \"darwin\": [\n \"{REDSHIFT_COREDATAPATH}/redshift4maya/{MAYA_VERSION}\",\n \"{MAYA_PLUG_IN_PATH}\"\n ]\n },\n \"MAYA_SCRIPT_PATH\": {\n \"windows\": [\n \"{REDSHIFT_COREDATAPATH}/Plugins/Maya/Common/scripts\",\n \"{MAYA_SCRIPT_PATH}\"\n ],\n \"linux\": [\n \"{REDSHIFT_COREDATAPATH}/redshift4maya/common/scripts\",\n \"{MAYA_SCRIPT_PATH}\"\n ],\n \"darwin\": [\n \"{REDSHIFT_COREDATAPATH}/redshift4maya/common/scripts\",\n \"{MAYA_SCRIPT_PATH}\"\n ]\n },\n \"REDSHIFT_PROCEDURALSPATH\": {\n \"windows\": [\n \"{REDSHIFT_COREDATAPATH}/Procedurals\",\n \"{REDSHIFT_PROCEDURALSPATH}\"\n ],\n \"linux\": [\n \"{REDSHIFT_COREDATAPATH}/procedurals\",\n \"{REDSHIFT_PROCEDURALSPATH}\"\n ],\n \"darwin\": [\n \"{REDSHIFT_COREDATAPATH}/procedurals\",\n \"{REDSHIFT_PROCEDURALSPATH}\"\n ]\n },\n \"REDSHIFT_MAYAEXTENSIONSPATH\": {\n \"windows\": [\n \"{REDSHIFT_COREDATAPATH}/Plugins/Maya/{MAYA_VERSION}/nt-x86-64/extensions\",\n \"{REDSHIFT_MAYAEXTENSIONSPATH}\"\n ],\n \"linux\": [\n \"{REDSHIFT_COREDATAPATH}/redshift4maya/{MAYA_VERSION}/extensions\",\n \"{REDSHIFT_MAYAEXTENSIONSPATH}\"\n ],\n \"darwin\": [\n \"{REDSHIFT_COREDATAPATH}/redshift4maya/{MAYA_VERSION}/extensions\",\n \"{REDSHIFT_MAYAEXTENSIONSPATH}\"\n ]\n },\n \"XBMLANGPATH\": {\n \"windows\": [\n \"{REDSHIFT_COREDATAPATH}/Plugins/Maya/Common/icons\",\n \"{XBMLANGPATH}\"\n ],\n \"linux\": [\n \"{REDSHIFT_COREDATAPATH}/redshift4maya/common/icons\",\n \"{XBMLANGPATH}\"\n ],\n \"darwin\": [\n \"{REDSHIFT_COREDATAPATH}/redshift4maya/common/icons\",\n \"{XBMLANGPATH}\"\n ]\n },\n \"MAYA_RENDER_DESC_PATH\": {\n \"windows\": [\n \"{REDSHIFT_COREDATAPATH}/Plugins/Maya/Common/rendererDesc\",\n \"{MAYA_RENDER_DESC_PATH}\"\n ],\n \"linux\": [\n \"{REDSHIFT_COREDATAPATH}/redshift4maya/common/rendererDesc\",\n \"{MAYA_RENDER_DESC_PATH}\"\n ],\n \"darwin\": [\n \"{REDSHIFT_COREDATAPATH}/redshift4maya/common/rendererDesc\",\n \"{MAYA_RENDER_DESC_PATH}\"\n ]\n },\n \"MAYA_CUSTOM_TEMPLATE_PATH\": {\n \"windows\": [\n \"{REDSHIFT_COREDATAPATH}/Plugins/Maya/Common/scripts/NETemplates\",\n \"{MAYA_CUSTOM_TEMPLATE_PATH}\"\n ],\n \"linux\": [\n \"{REDSHIFT_COREDATAPATH}/redshift4maya/common/scripts/NETemplates\",\n \"{MAYA_CUSTOM_TEMPLATE_PATH}\"\n ],\n \"darwin\": [\n \"{REDSHIFT_COREDATAPATH}/redshift4maya/common/scripts/NETemplates\",\n \"{MAYA_CUSTOM_TEMPLATE_PATH}\"\n ]\n },\n \"PATH\": {\n \"windows\": [\n \"{REDSHIFT_COREDATAPATH}/bin\",\n \"{PATH}\"\n ]\n }\n}"
},
{
"name": "redshift3dsmax",
"label": "Redshift for 3dsmax (example)",
"variants": [
{
"name": "3-5-19",
"label": "",
"host_names": [
"max"
],
"environment": "{\n \"REDSHIFT_VERSION\": \"3.5.19\"\n}",
"app_variants": []
}
],
"environment": "{\n \"_comment_\": \"{STUDIO_SW} points to software repository. Can be defined in Core addon globally\",\n\n \"REDSHIFT_COREDATAPATH\": {\n \"darwin\": \"{STUDIO_SW}/APP/REDSHIFT/{REDSHIFT_VERSION}/MAC\",\n \"linux\": \"{STUDIO_SW}/APP/REDSHIFT/{REDSHIFT_VERSION}/LINUX\",\n \"windows\": \"{STUDIO_SW}/APP/REDSHIFT/{REDSHIFT_VERSION}/WINDOWS\"\n },\n \"REDSHIFT_ABORTONLICENSEFAIL\": \"0\",\n \"REDSHIFT_PROCEDURALSPATH\": {\n \"windows\": [\n \"{REDSHIFT_COREDATAPATH}/Procedurals\",\n \"{REDSHIFT_PROCEDURALSPATH}\"\n ],\n \"linux\": [\n \"{REDSHIFT_COREDATAPATH}/procedurals\",\n \"{REDSHIFT_PROCEDURALSPATH}\"\n ],\n \"darwin\": [\n \"{REDSHIFT_COREDATAPATH}/procedurals\",\n \"{REDSHIFT_PROCEDURALSPATH}\"\n ]\n },\n \"PATH\": {\n \"windows\": [\n \"{REDSHIFT_COREDATAPATH}/bin\",\n \"{PATH}\"\n ]\n }\n}"
},
{
"name": "rendermanMaya",
"label": "Renderman for Maya (example)",
"variants": [
{
"name": "24-3-maya",
"label": "24.3 RFM",
"host_names": [
"maya"
],
"environment": "{\n \"RFMTREE\": {\n \"windows\": \"C:\\\\Program Files\\\\Pixar\\\\RenderManForMaya-24.3\",\n \"darwin\": \"/Applications/Pixar/RenderManForMaya-24.3\",\n \"linux\": \"/opt/pixar/RenderManForMaya-24.3\"\n },\n \"RMANTREE\": {\n \"windows\": \"C:\\\\Program Files\\\\Pixar\\\\RenderManProServer-24.3\",\n \"darwin\": \"/Applications/Pixar/RenderManProServer-24.3\",\n \"linux\": \"/opt/pixar/RenderManProServer-24.3\"\n }\n}",
"app_variants": [
"maya/2022"
]
}
],
"environment": "{\n \"_comment_\": \"{STUDIO_SW} points to software repository. Can be defined in Core addon globally\",\n\n \"RFMTREE\": {\n \"darwin\": \"{STUDIO_SW}/APP/RENDERMAN/{RM_VERSION}/MAC/MAYA\",\n \"linux\": \"{STUDIO_SW}/APP/RENDERMAN/{RM_VERSION}/LINUX/MAYA\",\n \"windows\": \"{STUDIO_SW}/APP/RENDERMAN/{RM_VERSION}/WINDOWS/MAYA\"\n },\n \"RMANTREE\": {\n \"darwin\": \"{STUDIO_SW}/APP/RENDERMAN/{RM_VERSION}/MAC/RenderManProServer-{RM_VERSION}\",\n \"linux\": \"{STUDIO_SW}/APP/RENDERMAN/{RM_VERSION}/LINUX/RenderManProServer-{RM_VERSION}\",\n \"windows\": \"{STUDIO_SW}/APP/RENDERMAN/{RM_VERSION}/WINDOWS/RenderManProServer-{RM_VERSION}\"\n },\n \"MAYA_MODULE_PATH\": [\n \"{STUDIO_SW}/APP/RENDERMAN\",\n \"{MAYA_MODULE_PATH}\"\n ],\n \"PIXAR_LICENSE_FILE\": \"{STUDIO_SW}/APP/RENDERMAN/pixar.license\",\n \"RFM_DO_NOT_CREATE_MODULE_FILE\": \"1\"\n}"
},
{
"name": "mGear",
"label": "mGear for Maya (example)",
"variants": [
{
"name": "4-0-7",
"label": "",
"host_names": [],
"environment": "{\n \"MGEAR_VERSION\": \"4.0.7\"\n}",
"app_variants": []
}
],
"environment": "{\n \"_comment_\": \"{STUDIO_SW} points to software repository. Can be defined in Core addon globally\",\n\n \"MGEAR_ROOT\": \"{STUDIO_SW}/APP/MGEAR/{MGEAR_VERSION}/MAYA{MAYA_VERSION}/windows/x64\",\n \"MAYA_MODULE_PATH\": [\n \"{STUDIO_SW}/APP/MGEAR/{MGEAR_VERSION}/release\",\n \"{MAYA_MODULE_PATH}\"\n ]\n}"
},
{
"name": "yetiMaya",
"label": "Yeti for Maya (example)",
"variants": [
{
"name": "4.2.11",
"label": "",
"host_names": [],
"environment": "{\n \"YETI_VERSION\": \"4.2.11\"\n}",
"app_variants": []
}
],
"environment": "{\n \"_comment_\": \"{STUDIO_SW} points to software repository. Can be defined in Core addon globally\",\n\n \"YETI_HOME\": {\n \"darwin\": \"{STUDIO_SW}/APP/YETI/{YETI_VERSION}/MAYA{MAYA_VERSION}/MAC\",\n \"linux\": \"{STUDIO_SW}/APP/YETI/{YETI_VERSION}/MAYA{MAYA_VERSION}/LINUX\",\n \"windows\": \"{STUDIO_SW}/APP/YETI/{YETI_VERSION}/MAYA{MAYA_VERSION}/WINDOWS\"\n },\n \"YETI_TMP\": {\n \"windows\": \"C:/temp\",\n \"darwin\": \"/tmp\",\n \"linux\": \"/tmp\"\n },\n \"peregrinel_LICENSE\": \"4202@35.158.197.250\",\n \"MAYA_MODULE_PATH\": [\n \"{STUDIO_SW}/APP/YETI\",\n \"{MAYA_MODULE_PATH}\"\n ]\n}"
},
{
"name": "vrayMaya",
"label": "Vray for Maya (example)",
"variants": [
{
"name": "6.10.01",
"label": "",
"host_names": [
""
],
"environment": "{\n \"VRAY_VERSION\": \"6.10.01\"\n}",
"app_variants": []
}
],
"environment": "{\n \"_comment_\": \"{STUDIO_SW} points to software repository. Can be defined in Core addon globally\",\n\n \"MAYA_MODULE_PATH\": {\n \"windows\": [\n \"{STUDIO_SW}/APP/VRAY/{VRAY_VERSION}/MAYA{MAYA_VERSION}/WINDOWS/maya_root/modules\",\n \"{MAYA_MODULE_PATH}\"\n ],\n \"linux\": [\n \"{STUDIO_SW}/APP/VRAY/{VRAY_VERSION}/MAYA{MAYA_VERSION}/LINUX/maya_root/modules\",\n \"{MAYA_MODULE_PATH}\"\n ],\n \"darwin\": [\n \"{STUDIO_SW}/APP/VRAY/{VRAY_VERSION}/MAYA{MAYA_VERSION}/MAC/maya_root/modules\",\n \"{MAYA_MODULE_PATH}\"\n ]\n },\n \"VRAY_AUTH_CLIENT_FILE_PATH\": \"{STUDIO_SW}/APP/VRAY\"\n}"
},
{
"name": "vraynuke",
"label": "Vray for Nuke (example)",
"variants": [
{
"name": "5-20-00",
"label": "",
"host_names": [
"nuke"
],
"environment": "{\n \"VRAYNUKE_VERSION\": \"5.20.00\"\n}",
"app_variants": []
}
],
"environment": "{\n \"_comment_\": \"{STUDIO_SW} points to software repository. Can be defined in Core addon globally\",\n\n \"VRAY_FOR_NUKE_13_0_PLUGINS\": {\n \"windows\": \"{STUDIO_SW}/APP/VRAYNUKE/{VRAYNUKE_VERSION}/NUKE{NUKE_VRAY_VERSION}/WINDOWS/nuke_vray/plugins/vray\"\n },\n \"NUKE_PATH\": {\n \"windows\": [\n \"{STUDIO_SW}/APP/VRAYNUKE/{VRAYNUKE_VERSION}/NUKE{NUKE_VRAY_VERSION}/WINDOWS/nuke_root\",\n \"{NUKE_PATH}\"\n ]\n },\n \"PATH\": {\n \"windows\": [\n \"{STUDIO_SW}/APP/VRAYNUKE/{VRAYNUKE_VERSION}/NUKE{NUKE_VRAY_VERSION}/WINDOWS/nuke_vray\",\n \"{PATH}\"\n ]\n },\n \"VRAY_AUTH_CLIENT_FILE_PATH\": \"{STUDIO_SW}/APP/VRAY\"\n}"
}
]
}

View file

@ -1,17 +0,0 @@
from .version import __version__
from .addon import (
get_fusion_version,
FusionAddon,
FUSION_ADDON_ROOT,
FUSION_VERSIONS_DICT,
)
__all__ = (
"__version__",
"get_fusion_version",
"FusionAddon",
"FUSION_ADDON_ROOT",
"FUSION_VERSIONS_DICT",
)

View file

@ -1,72 +0,0 @@
import os
import re
from ayon_core.addon import AYONAddon, IHostAddon
from ayon_core.lib import Logger
from .version import __version__
FUSION_ADDON_ROOT = os.path.dirname(os.path.abspath(__file__))
# FUSION_VERSIONS_DICT is used by the pre-launch hooks
# The keys correspond to all currently supported Fusion versions
# Each value is a list of corresponding Python home variables and a profile
# number, which is used by the profile hook to set Fusion profile variables.
FUSION_VERSIONS_DICT = {
9: ("FUSION_PYTHON36_HOME", 9),
16: ("FUSION16_PYTHON36_HOME", 16),
17: ("FUSION16_PYTHON36_HOME", 16),
18: ("FUSION_PYTHON3_HOME", 16),
}
def get_fusion_version(app_name):
"""
The function is triggered by the prelaunch hooks to get the fusion version.
`app_name` is obtained by prelaunch hooks from the
`launch_context.env.get("AYON_APP_NAME")`.
To get a correct Fusion version, a version number should be present
in the `applications/fusion/variants` key
of the Blackmagic Fusion Application Settings.
"""
log = Logger.get_logger(__name__)
if not app_name:
return
app_version_candidates = re.findall(r"\d+", app_name)
if not app_version_candidates:
return
for app_version in app_version_candidates:
if int(app_version) in FUSION_VERSIONS_DICT:
return int(app_version)
else:
log.info(
"Unsupported Fusion version: {app_version}".format(
app_version=app_version
)
)
class FusionAddon(AYONAddon, IHostAddon):
name = "fusion"
version = __version__
host_name = "fusion"
def get_launch_hook_paths(self, app):
if app.host_name != self.host_name:
return []
return [os.path.join(FUSION_ADDON_ROOT, "hooks")]
def add_implementation_envs(self, env, app):
# Set default values if are not already set via settings
defaults = {"AYON_LOG_NO_COLORS": "1"}
for key, value in defaults.items():
if not env.get(key):
env[key] = value
def get_workfile_extensions(self):
return [".comp"]

View file

@ -1,39 +0,0 @@
from .pipeline import (
FusionHost,
ls,
imprint_container,
parse_container
)
from .lib import (
maintained_selection,
update_frame_range,
set_current_context_framerange,
get_current_comp,
get_bmd_library,
comp_lock_and_undo_chunk
)
from .menu import launch_ayon_menu
__all__ = [
# pipeline
"FusionHost",
"ls",
"imprint_container",
"parse_container",
# lib
"maintained_selection",
"update_frame_range",
"set_current_context_framerange",
"get_current_comp",
"get_bmd_library",
"comp_lock_and_undo_chunk",
# menu
"launch_ayon_menu",
]

View file

@ -1,112 +0,0 @@
import pyblish.api
from ayon_fusion.api.lib import get_current_comp
from ayon_core.pipeline.publish import get_errored_instances_from_context
class SelectInvalidAction(pyblish.api.Action):
"""Select invalid nodes in Fusion when plug-in failed.
To retrieve the invalid nodes this assumes a static `get_invalid()`
method is available on the plugin.
"""
label = "Select invalid"
on = "failed" # This action is only available on a failed plug-in
icon = "search" # Icon from Awesome Icon
def process(self, context, plugin):
errored_instances = get_errored_instances_from_context(
context,
plugin=plugin,
)
# Get the invalid nodes for the plug-ins
self.log.info("Finding invalid nodes..")
invalid = list()
for instance in errored_instances:
invalid_nodes = plugin.get_invalid(instance)
if invalid_nodes:
if isinstance(invalid_nodes, (list, tuple)):
invalid.extend(invalid_nodes)
else:
self.log.warning(
"Plug-in returned to be invalid, "
"but has no selectable nodes."
)
if not invalid:
# Assume relevant comp is current comp and clear selection
self.log.info("No invalid tools found.")
comp = get_current_comp()
flow = comp.CurrentFrame.FlowView
flow.Select() # No args equals clearing selection
return
# Assume a single comp
first_tool = invalid[0]
comp = first_tool.Comp()
flow = comp.CurrentFrame.FlowView
flow.Select() # No args equals clearing selection
names = set()
for tool in invalid:
flow.Select(tool, True)
comp.SetActiveTool(tool)
names.add(tool.Name)
self.log.info(
"Selecting invalid tools: %s" % ", ".join(sorted(names))
)
class SelectToolAction(pyblish.api.Action):
"""Select invalid output tool in Fusion when plug-in failed.
"""
label = "Select saver"
on = "failed" # This action is only available on a failed plug-in
icon = "search" # Icon from Awesome Icon
def process(self, context, plugin):
errored_instances = get_errored_instances_from_context(
context,
plugin=plugin,
)
# Get the invalid nodes for the plug-ins
self.log.info("Finding invalid nodes..")
tools = []
for instance in errored_instances:
tool = instance.data.get("tool")
if tool is not None:
tools.append(tool)
else:
self.log.warning(
"Plug-in returned to be invalid, "
f"but has no saver for instance {instance.name}."
)
if not tools:
# Assume relevant comp is current comp and clear selection
self.log.info("No invalid tools found.")
comp = get_current_comp()
flow = comp.CurrentFrame.FlowView
flow.Select() # No args equals clearing selection
return
# Assume a single comp
first_tool = tools[0]
comp = first_tool.Comp()
flow = comp.CurrentFrame.FlowView
flow.Select() # No args equals clearing selection
names = set()
for tool in tools:
flow.Select(tool, True)
comp.SetActiveTool(tool)
names.add(tool.Name)
self.log.info(
"Selecting invalid tools: %s" % ", ".join(sorted(names))
)

View file

@ -1,402 +0,0 @@
import os
import sys
import re
import contextlib
from ayon_core.lib import Logger, BoolDef, UILabelDef
from ayon_core.style import load_stylesheet
from ayon_core.pipeline import registered_host
from ayon_core.pipeline.create import CreateContext
from ayon_core.pipeline.context_tools import get_current_folder_entity
self = sys.modules[__name__]
self._project = None
def update_frame_range(start, end, comp=None, set_render_range=True,
handle_start=0, handle_end=0):
"""Set Fusion comp's start and end frame range
Args:
start (float, int): start frame
end (float, int): end frame
comp (object, Optional): comp object from fusion
set_render_range (bool, Optional): When True this will also set the
composition's render start and end frame.
handle_start (float, int, Optional): frame handles before start frame
handle_end (float, int, Optional): frame handles after end frame
Returns:
None
"""
if not comp:
comp = get_current_comp()
# Convert any potential none type to zero
handle_start = handle_start or 0
handle_end = handle_end or 0
attrs = {
"COMPN_GlobalStart": start - handle_start,
"COMPN_GlobalEnd": end + handle_end
}
# set frame range
if set_render_range:
attrs.update({
"COMPN_RenderStart": start,
"COMPN_RenderEnd": end
})
with comp_lock_and_undo_chunk(comp):
comp.SetAttrs(attrs)
def set_current_context_framerange(folder_entity=None):
"""Set Comp's frame range based on current folder."""
if folder_entity is None:
folder_entity = get_current_folder_entity(
fields={"attrib.frameStart",
"attrib.frameEnd",
"attrib.handleStart",
"attrib.handleEnd"})
folder_attributes = folder_entity["attrib"]
start = folder_attributes["frameStart"]
end = folder_attributes["frameEnd"]
handle_start = folder_attributes["handleStart"]
handle_end = folder_attributes["handleEnd"]
update_frame_range(start, end, set_render_range=True,
handle_start=handle_start,
handle_end=handle_end)
def set_current_context_fps(folder_entity=None):
"""Set Comp's frame rate (FPS) to based on current asset"""
if folder_entity is None:
folder_entity = get_current_folder_entity(fields={"attrib.fps"})
fps = float(folder_entity["attrib"].get("fps", 24.0))
comp = get_current_comp()
comp.SetPrefs({
"Comp.FrameFormat.Rate": fps,
})
def set_current_context_resolution(folder_entity=None):
"""Set Comp's resolution width x height default based on current folder"""
if folder_entity is None:
folder_entity = get_current_folder_entity(
fields={"attrib.resolutionWidth", "attrib.resolutionHeight"})
folder_attributes = folder_entity["attrib"]
width = folder_attributes["resolutionWidth"]
height = folder_attributes["resolutionHeight"]
comp = get_current_comp()
print("Setting comp frame format resolution to {}x{}".format(width,
height))
comp.SetPrefs({
"Comp.FrameFormat.Width": width,
"Comp.FrameFormat.Height": height,
})
def validate_comp_prefs(comp=None, force_repair=False):
"""Validate current comp defaults with folder settings.
Validates fps, resolutionWidth, resolutionHeight, aspectRatio.
This does *not* validate frameStart, frameEnd, handleStart and handleEnd.
"""
if comp is None:
comp = get_current_comp()
log = Logger.get_logger("validate_comp_prefs")
fields = {
"path",
"attrib.fps",
"attrib.resolutionWidth",
"attrib.resolutionHeight",
"attrib.pixelAspect",
}
folder_entity = get_current_folder_entity(fields=fields)
folder_path = folder_entity["path"]
folder_attributes = folder_entity["attrib"]
comp_frame_format_prefs = comp.GetPrefs("Comp.FrameFormat")
# Pixel aspect ratio in Fusion is set as AspectX and AspectY so we convert
# the data to something that is more sensible to Fusion
folder_attributes["pixelAspectX"] = folder_attributes.pop("pixelAspect")
folder_attributes["pixelAspectY"] = 1.0
validations = [
("fps", "Rate", "FPS"),
("resolutionWidth", "Width", "Resolution Width"),
("resolutionHeight", "Height", "Resolution Height"),
("pixelAspectX", "AspectX", "Pixel Aspect Ratio X"),
("pixelAspectY", "AspectY", "Pixel Aspect Ratio Y")
]
invalid = []
for key, comp_key, label in validations:
folder_value = folder_attributes[key]
comp_value = comp_frame_format_prefs.get(comp_key)
if folder_value != comp_value:
invalid_msg = "{} {} should be {}".format(label,
comp_value,
folder_value)
invalid.append(invalid_msg)
if not force_repair:
# Do not log warning if we force repair anyway
log.warning(
"Comp {pref} {value} does not match folder "
"'{folder_path}' {pref} {folder_value}".format(
pref=label,
value=comp_value,
folder_path=folder_path,
folder_value=folder_value)
)
if invalid:
def _on_repair():
attributes = dict()
for key, comp_key, _label in validations:
value = folder_attributes[key]
comp_key_full = "Comp.FrameFormat.{}".format(comp_key)
attributes[comp_key_full] = value
comp.SetPrefs(attributes)
if force_repair:
log.info("Applying default Comp preferences..")
_on_repair()
return
from . import menu
from ayon_core.tools.utils import SimplePopup
dialog = SimplePopup(parent=menu.menu)
dialog.setWindowTitle("Fusion comp has invalid configuration")
msg = "Comp preferences mismatches '{}'".format(folder_path)
msg += "\n" + "\n".join(invalid)
dialog.set_message(msg)
dialog.set_button_text("Repair")
dialog.on_clicked.connect(_on_repair)
dialog.show()
dialog.raise_()
dialog.activateWindow()
dialog.setStyleSheet(load_stylesheet())
@contextlib.contextmanager
def maintained_selection(comp=None):
"""Reset comp selection from before the context after the context"""
if comp is None:
comp = get_current_comp()
previous_selection = comp.GetToolList(True).values()
try:
yield
finally:
flow = comp.CurrentFrame.FlowView
flow.Select() # No args equals clearing selection
if previous_selection:
for tool in previous_selection:
flow.Select(tool, True)
@contextlib.contextmanager
def maintained_comp_range(comp=None,
global_start=True,
global_end=True,
render_start=True,
render_end=True):
"""Reset comp frame ranges from before the context after the context"""
if comp is None:
comp = get_current_comp()
comp_attrs = comp.GetAttrs()
preserve_attrs = {}
if global_start:
preserve_attrs["COMPN_GlobalStart"] = comp_attrs["COMPN_GlobalStart"]
if global_end:
preserve_attrs["COMPN_GlobalEnd"] = comp_attrs["COMPN_GlobalEnd"]
if render_start:
preserve_attrs["COMPN_RenderStart"] = comp_attrs["COMPN_RenderStart"]
if render_end:
preserve_attrs["COMPN_RenderEnd"] = comp_attrs["COMPN_RenderEnd"]
try:
yield
finally:
comp.SetAttrs(preserve_attrs)
def get_frame_path(path):
"""Get filename for the Fusion Saver with padded number as '#'
>>> get_frame_path("C:/test.exr")
('C:/test', 4, '.exr')
>>> get_frame_path("filename.00.tif")
('filename.', 2, '.tif')
>>> get_frame_path("foobar35.tif")
('foobar', 2, '.tif')
Args:
path (str): The path to render to.
Returns:
tuple: head, padding, tail (extension)
"""
filename, ext = os.path.splitext(path)
# Find a final number group
match = re.match('.*?([0-9]+)$', filename)
if match:
padding = len(match.group(1))
# remove number from end since fusion
# will swap it with the frame number
filename = filename[:-padding]
else:
padding = 4 # default Fusion padding
return filename, padding, ext
def get_fusion_module():
"""Get current Fusion instance"""
fusion = getattr(sys.modules["__main__"], "fusion", None)
return fusion
def get_bmd_library():
"""Get bmd library"""
bmd = getattr(sys.modules["__main__"], "bmd", None)
return bmd
def get_current_comp():
"""Get current comp in this session"""
fusion = get_fusion_module()
if fusion is not None:
comp = fusion.CurrentComp
return comp
@contextlib.contextmanager
def comp_lock_and_undo_chunk(
comp,
undo_queue_name="Script CMD",
keep_undo=True,
):
"""Lock comp and open an undo chunk during the context"""
try:
comp.Lock()
comp.StartUndo(undo_queue_name)
yield
finally:
comp.Unlock()
comp.EndUndo(keep_undo)
def update_content_on_context_change():
"""Update all Creator instances to current asset"""
host = registered_host()
context = host.get_current_context()
folder_path = context["folder_path"]
task = context["task_name"]
create_context = CreateContext(host, reset=True)
for instance in create_context.instances:
instance_folder_path = instance.get("folderPath")
if instance_folder_path and instance_folder_path != folder_path:
instance["folderPath"] = folder_path
instance_task = instance.get("task")
if instance_task and instance_task != task:
instance["task"] = task
create_context.save_changes()
def prompt_reset_context():
"""Prompt the user what context settings to reset.
This prompt is used on saving to a different task to allow the scene to
get matched to the new context.
"""
# TODO: Cleanup this prototyped mess of imports and odd dialog
from ayon_core.tools.attribute_defs.dialog import (
AttributeDefinitionsDialog
)
from qtpy import QtCore
definitions = [
UILabelDef(
label=(
"You are saving your workfile into a different folder or task."
"\n\n"
"Would you like to update some settings to the new context?\n"
)
),
BoolDef(
"fps",
label="FPS",
tooltip="Reset Comp FPS",
default=True
),
BoolDef(
"frame_range",
label="Frame Range",
tooltip="Reset Comp start and end frame ranges",
default=True
),
BoolDef(
"resolution",
label="Comp Resolution",
tooltip="Reset Comp resolution",
default=True
),
BoolDef(
"instances",
label="Publish instances",
tooltip="Update all publish instance's folder and task to match "
"the new folder and task",
default=True
),
]
dialog = AttributeDefinitionsDialog(definitions)
dialog.setWindowFlags(
dialog.windowFlags() | QtCore.Qt.WindowStaysOnTopHint
)
dialog.setWindowTitle("Saving to different context.")
dialog.setStyleSheet(load_stylesheet())
if not dialog.exec_():
return None
options = dialog.get_values()
folder_entity = get_current_folder_entity()
if options["frame_range"]:
set_current_context_framerange(folder_entity)
if options["fps"]:
set_current_context_fps(folder_entity)
if options["resolution"]:
set_current_context_resolution(folder_entity)
if options["instances"]:
update_content_on_context_change()
dialog.deleteLater()

View file

@ -1,190 +0,0 @@
import os
import sys
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.utils import host_tools
from ayon_core.style import load_stylesheet
from ayon_core.lib import register_event_callback
from ayon_fusion.scripts import (
duplicate_with_inputs,
)
from ayon_fusion.api.lib import (
set_current_context_framerange,
set_current_context_resolution,
)
from ayon_core.pipeline import get_current_folder_path
from ayon_core.resources import get_ayon_icon_filepath
from ayon_core.tools.utils import get_qt_app
from .pipeline import FusionEventHandler
from .pulse import FusionPulse
MENU_LABEL = os.environ["AYON_MENU_LABEL"]
self = sys.modules[__name__]
self.menu = None
class AYONMenu(QtWidgets.QWidget):
def __init__(self, *args, **kwargs):
super(AYONMenu, self).__init__(*args, **kwargs)
self.setObjectName(f"{MENU_LABEL}Menu")
icon_path = get_ayon_icon_filepath()
icon = QtGui.QIcon(icon_path)
self.setWindowIcon(icon)
self.setWindowFlags(
QtCore.Qt.Window
| QtCore.Qt.CustomizeWindowHint
| QtCore.Qt.WindowTitleHint
| QtCore.Qt.WindowMinimizeButtonHint
| QtCore.Qt.WindowCloseButtonHint
| QtCore.Qt.WindowStaysOnTopHint
)
self.render_mode_widget = None
self.setWindowTitle(MENU_LABEL)
context_label = QtWidgets.QLabel("Context", self)
context_label.setStyleSheet(
"""QLabel {
font-size: 14px;
font-weight: 600;
color: #5f9fb8;
}"""
)
context_label.setAlignment(QtCore.Qt.AlignHCenter)
workfiles_btn = QtWidgets.QPushButton("Workfiles...", self)
create_btn = QtWidgets.QPushButton("Create...", self)
load_btn = QtWidgets.QPushButton("Load...", self)
publish_btn = QtWidgets.QPushButton("Publish...", self)
manager_btn = QtWidgets.QPushButton("Manage...", self)
libload_btn = QtWidgets.QPushButton("Library...", self)
set_framerange_btn = QtWidgets.QPushButton("Set Frame Range", self)
set_resolution_btn = QtWidgets.QPushButton("Set Resolution", self)
duplicate_with_inputs_btn = QtWidgets.QPushButton(
"Duplicate with input connections", self
)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(10, 20, 10, 20)
layout.addWidget(context_label)
layout.addSpacing(20)
layout.addWidget(workfiles_btn)
layout.addSpacing(20)
layout.addWidget(create_btn)
layout.addWidget(load_btn)
layout.addWidget(publish_btn)
layout.addWidget(manager_btn)
layout.addSpacing(20)
layout.addWidget(libload_btn)
layout.addSpacing(20)
layout.addWidget(set_framerange_btn)
layout.addWidget(set_resolution_btn)
layout.addSpacing(20)
layout.addWidget(duplicate_with_inputs_btn)
self.setLayout(layout)
# Store reference so we can update the label
self.context_label = context_label
workfiles_btn.clicked.connect(self.on_workfile_clicked)
create_btn.clicked.connect(self.on_create_clicked)
publish_btn.clicked.connect(self.on_publish_clicked)
load_btn.clicked.connect(self.on_load_clicked)
manager_btn.clicked.connect(self.on_manager_clicked)
libload_btn.clicked.connect(self.on_libload_clicked)
duplicate_with_inputs_btn.clicked.connect(
self.on_duplicate_with_inputs_clicked
)
set_resolution_btn.clicked.connect(self.on_set_resolution_clicked)
set_framerange_btn.clicked.connect(self.on_set_framerange_clicked)
self._callbacks = []
self.register_callback("taskChanged", self.on_task_changed)
self.on_task_changed()
# Force close current process if Fusion is closed
self._pulse = FusionPulse(parent=self)
self._pulse.start()
# Detect Fusion events as AYON events
self._event_handler = FusionEventHandler(parent=self)
self._event_handler.start()
def on_task_changed(self):
# Update current context label
label = get_current_folder_path()
self.context_label.setText(label)
def register_callback(self, name, fn):
# Create a wrapper callback that we only store
# for as long as we want it to persist as callback
def _callback(*args):
fn()
self._callbacks.append(_callback)
register_event_callback(name, _callback)
def deregister_all_callbacks(self):
self._callbacks[:] = []
def on_workfile_clicked(self):
host_tools.show_workfiles()
def on_create_clicked(self):
host_tools.show_publisher(tab="create")
def on_publish_clicked(self):
host_tools.show_publisher(tab="publish")
def on_load_clicked(self):
host_tools.show_loader(use_context=True)
def on_manager_clicked(self):
host_tools.show_scene_inventory()
def on_libload_clicked(self):
host_tools.show_library_loader()
def on_duplicate_with_inputs_clicked(self):
duplicate_with_inputs.duplicate_with_input_connections()
def on_set_resolution_clicked(self):
set_current_context_resolution()
def on_set_framerange_clicked(self):
set_current_context_framerange()
def launch_ayon_menu():
app = get_qt_app()
ayon_menu = AYONMenu()
stylesheet = load_stylesheet()
ayon_menu.setStyleSheet(stylesheet)
ayon_menu.show()
self.menu = ayon_menu
result = app.exec_()
print("Shutting down..")
sys.exit(result)

View file

@ -1,439 +0,0 @@
"""
Basic avalon integration
"""
import os
import sys
import logging
import contextlib
from pathlib import Path
import pyblish.api
from qtpy import QtCore
from ayon_core.lib import (
Logger,
register_event_callback,
emit_event
)
from ayon_core.pipeline import (
register_loader_plugin_path,
register_creator_plugin_path,
register_inventory_action_path,
AVALON_CONTAINER_ID,
)
from ayon_core.pipeline.load import any_outdated_containers
from ayon_core.host import HostBase, IWorkfileHost, ILoadHost, IPublishHost
from ayon_core.tools.utils import host_tools
from ayon_fusion import FUSION_ADDON_ROOT
from .lib import (
get_current_comp,
validate_comp_prefs,
prompt_reset_context
)
log = Logger.get_logger(__name__)
PLUGINS_DIR = os.path.join(FUSION_ADDON_ROOT, "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")
INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
# Track whether the workfile tool is about to save
_about_to_save = False
class FusionLogHandler(logging.Handler):
# Keep a reference to fusion's Print function (Remote Object)
_print = None
@property
def print(self):
if self._print is not None:
# Use cached
return self._print
_print = getattr(sys.modules["__main__"], "fusion").Print
if _print is None:
# Backwards compatibility: Print method on Fusion instance was
# added around Fusion 17.4 and wasn't available on PyRemote Object
# before
_print = get_current_comp().Print
self._print = _print
return _print
def emit(self, record):
entry = self.format(record)
self.print(entry)
class FusionHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
name = "fusion"
def install(self):
"""Install fusion-specific functionality of AYON.
This is where you install menus and register families, data
and loaders into fusion.
It is called automatically when installing via
`ayon_core.pipeline.install_host(ayon_fusion.api)`
See the Maya equivalent for inspiration on how to implement this.
"""
# Remove all handlers associated with the root logger object, because
# that one always logs as "warnings" incorrectly.
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
# Attach default logging handler that prints to active comp
logger = logging.getLogger()
formatter = logging.Formatter(fmt="%(message)s\n")
handler = FusionLogHandler()
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
pyblish.api.register_host("fusion")
pyblish.api.register_plugin_path(PUBLISH_PATH)
log.info("Registering Fusion plug-ins..")
register_loader_plugin_path(LOAD_PATH)
register_creator_plugin_path(CREATE_PATH)
register_inventory_action_path(INVENTORY_PATH)
# Register events
register_event_callback("open", on_after_open)
register_event_callback("workfile.save.before", before_workfile_save)
register_event_callback("save", on_save)
register_event_callback("new", on_new)
register_event_callback("taskChanged", on_task_changed)
# region workfile io api
def has_unsaved_changes(self):
comp = get_current_comp()
return comp.GetAttrs()["COMPB_Modified"]
def get_workfile_extensions(self):
return [".comp"]
def save_workfile(self, dst_path=None):
comp = get_current_comp()
comp.Save(dst_path)
def open_workfile(self, filepath):
# Hack to get fusion, see
# ayon_fusion.api.pipeline.get_current_comp()
fusion = getattr(sys.modules["__main__"], "fusion", None)
return fusion.LoadComp(filepath)
def get_current_workfile(self):
comp = get_current_comp()
current_filepath = comp.GetAttrs()["COMPS_FileName"]
if not current_filepath:
return None
return current_filepath
def work_root(self, session):
work_dir = session["AYON_WORKDIR"]
scene_dir = session.get("AVALON_SCENEDIR")
if scene_dir:
return os.path.join(work_dir, scene_dir)
else:
return work_dir
# endregion
@contextlib.contextmanager
def maintained_selection(self):
from .lib import maintained_selection
return maintained_selection()
def get_containers(self):
return ls()
def update_context_data(self, data, changes):
comp = get_current_comp()
comp.SetData("openpype", data)
def get_context_data(self):
comp = get_current_comp()
return comp.GetData("openpype") or {}
def on_new(event):
comp = event["Rets"]["comp"]
validate_comp_prefs(comp, force_repair=True)
def on_save(event):
comp = event["sender"]
validate_comp_prefs(comp)
# We are now starting the actual save directly
global _about_to_save
_about_to_save = False
def on_task_changed():
global _about_to_save
print(f"Task changed: {_about_to_save}")
# TODO: Only do this if not headless
if _about_to_save:
# Let's prompt the user to update the context settings or not
prompt_reset_context()
def on_after_open(event):
comp = event["sender"]
validate_comp_prefs(comp)
if any_outdated_containers():
log.warning("Scene has outdated content.")
# Find AYON menu to attach to
from . import menu
def _on_show_scene_inventory():
# ensure that comp is active
frame = comp.CurrentFrame
if not frame:
print("Comp is closed, skipping show scene inventory")
return
frame.ActivateFrame() # raise comp window
host_tools.show_scene_inventory()
from ayon_core.tools.utils import SimplePopup
from ayon_core.style import load_stylesheet
dialog = SimplePopup(parent=menu.menu)
dialog.setWindowTitle("Fusion comp has outdated content")
dialog.set_message("There are outdated containers in "
"your Fusion comp.")
dialog.on_clicked.connect(_on_show_scene_inventory)
dialog.show()
dialog.raise_()
dialog.activateWindow()
dialog.setStyleSheet(load_stylesheet())
def before_workfile_save(event):
# Due to Fusion's external python process design we can't really
# detect whether the current Fusion environment matches the one the artists
# expects it to be. For example, our pipeline python process might
# have been shut down, and restarted - which will restart it to the
# environment Fusion started with; not necessarily where the artist
# is currently working.
# The `_about_to_save` var is used to detect context changes when
# saving into another asset. If we keep it False it will be ignored
# as context change. As such, before we change tasks we will only
# consider it the current filepath is within the currently known
# AVALON_WORKDIR. This way we avoid false positives of thinking it's
# saving to another context and instead sometimes just have false negatives
# where we fail to show the "Update on task change" prompt.
comp = get_current_comp()
filepath = comp.GetAttrs()["COMPS_FileName"]
workdir = os.environ.get("AYON_WORKDIR")
if Path(workdir) in Path(filepath).parents:
global _about_to_save
_about_to_save = True
def ls():
"""List containers from active Fusion scene
This is the host-equivalent of api.ls(), but instead of listing
assets on disk, it lists assets already loaded in Fusion; once loaded
they are called 'containers'
Yields:
dict: container
"""
comp = get_current_comp()
tools = comp.GetToolList(False).values()
for tool in tools:
container = parse_container(tool)
if container:
yield container
def imprint_container(tool,
name,
namespace,
context,
loader=None):
"""Imprint a Loader with metadata
Containerisation enables a tracking of version, author and origin
for loaded assets.
Arguments:
tool (object): The node in Fusion to imprint as container, usually a
Loader.
name (str): Name of resulting assembly
namespace (str): Namespace under which to host container
context (dict): Asset information
loader (str, optional): Name of loader used to produce this container.
Returns:
None
"""
data = [
("schema", "openpype:container-2.0"),
("id", AVALON_CONTAINER_ID),
("name", str(name)),
("namespace", str(namespace)),
("loader", str(loader)),
("representation", context["representation"]["id"]),
]
for key, value in data:
tool.SetData("avalon.{}".format(key), value)
def parse_container(tool):
"""Returns imprinted container data of a tool
This reads the imprinted data from `imprint_container`.
"""
data = tool.GetData('avalon')
if not isinstance(data, dict):
return
# If not all required data return the empty container
required = ['schema', 'id', 'name',
'namespace', 'loader', 'representation']
if not all(key in data for key in required):
return
container = {key: data[key] for key in required}
# Store the tool's name
container["objectName"] = tool.Name
# Store reference to the tool object
container["_tool"] = tool
return container
class FusionEventThread(QtCore.QThread):
"""QThread which will periodically ping Fusion app for any events.
The fusion.UIManager must be set up to be notified of events before they'll
be reported by this thread, for example:
fusion.UIManager.AddNotify("Comp_Save", None)
"""
on_event = QtCore.Signal(dict)
def run(self):
app = getattr(sys.modules["__main__"], "app", None)
if app is None:
# No Fusion app found
return
# As optimization store the GetEvent method directly because every
# getattr of UIManager.GetEvent tries to resolve the Remote Function
# through the PyRemoteObject
get_event = app.UIManager.GetEvent
delay = int(os.environ.get("AYON_FUSION_CALLBACK_INTERVAL", 1000))
while True:
if self.isInterruptionRequested():
return
# Process all events that have been queued up until now
while True:
event = get_event(False)
if not event:
break
self.on_event.emit(event)
# Wait some time before processing events again
# to not keep blocking the UI
self.msleep(delay)
class FusionEventHandler(QtCore.QObject):
"""Emits AYON events based on Fusion events captured in a QThread.
This will emit the following AYON events based on Fusion actions:
save: Comp_Save, Comp_SaveAs
open: Comp_Opened
new: Comp_New
To use this you can attach it to you Qt UI so it runs in the background.
E.g.
>>> handler = FusionEventHandler(parent=window)
>>> handler.start()
"""
ACTION_IDS = [
"Comp_Save",
"Comp_SaveAs",
"Comp_New",
"Comp_Opened"
]
def __init__(self, parent=None):
super(FusionEventHandler, self).__init__(parent=parent)
# Set up Fusion event callbacks
fusion = getattr(sys.modules["__main__"], "fusion", None)
ui = fusion.UIManager
# Add notifications for the ones we want to listen to
notifiers = []
for action_id in self.ACTION_IDS:
notifier = ui.AddNotify(action_id, None)
notifiers.append(notifier)
# TODO: Not entirely sure whether these must be kept to avoid
# garbage collection
self._notifiers = notifiers
self._event_thread = FusionEventThread(parent=self)
self._event_thread.on_event.connect(self._on_event)
def start(self):
self._event_thread.start()
def stop(self):
self._event_thread.stop()
def _on_event(self, event):
"""Handle Fusion events to emit AYON events"""
if not event:
return
what = event["what"]
# Comp Save
if what in {"Comp_Save", "Comp_SaveAs"}:
if not event["Rets"].get("success"):
# If the Save action is cancelled it will still emit an
# event but with "success": False so we ignore those cases
return
# Comp was saved
emit_event("save", data=event)
return
# Comp New
elif what in {"Comp_New"}:
emit_event("new", data=event)
# Comp Opened
elif what in {"Comp_Opened"}:
emit_event("open", data=event)

View file

@ -1,278 +0,0 @@
from copy import deepcopy
import os
from ayon_fusion.api import (
get_current_comp,
comp_lock_and_undo_chunk,
)
from ayon_core.lib import (
BoolDef,
EnumDef,
)
from ayon_core.pipeline import (
Creator,
CreatedInstance,
AVALON_INSTANCE_ID,
AYON_INSTANCE_ID,
)
from ayon_core.pipeline.workfile import get_workdir
from ayon_api import (
get_project,
get_folder_by_path,
get_task_by_name
)
class GenericCreateSaver(Creator):
default_variants = ["Main", "Mask"]
description = "Fusion Saver to generate image sequence"
icon = "fa5.eye"
instance_attributes = [
"reviewable"
]
settings_category = "fusion"
image_format = "exr"
# TODO: This should be renamed together with Nuke so it is aligned
temp_rendering_path_template = (
"{workdir}/renders/fusion/{product[name]}/"
"{product[name]}.{frame}.{ext}"
)
def create(self, product_name, instance_data, pre_create_data):
self.pass_pre_attributes_to_instance(instance_data, pre_create_data)
instance = CreatedInstance(
product_type=self.product_type,
product_name=product_name,
data=instance_data,
creator=self,
)
data = instance.data_to_store()
comp = get_current_comp()
with comp_lock_and_undo_chunk(comp):
args = (-32768, -32768) # Magical position numbers
saver = comp.AddTool("Saver", *args)
self._update_tool_with_data(saver, data=data)
# Register the CreatedInstance
self._imprint(saver, data)
# Insert the transient data
instance.transient_data["tool"] = saver
self._add_instance_to_context(instance)
return instance
def collect_instances(self):
comp = get_current_comp()
tools = comp.GetToolList(False, "Saver").values()
for tool in tools:
data = self.get_managed_tool_data(tool)
if not data:
continue
# Add instance
created_instance = CreatedInstance.from_existing(data, self)
# Collect transient data
created_instance.transient_data["tool"] = tool
self._add_instance_to_context(created_instance)
def update_instances(self, update_list):
for created_inst, _changes in update_list:
new_data = created_inst.data_to_store()
tool = created_inst.transient_data["tool"]
self._update_tool_with_data(tool, new_data)
self._imprint(tool, new_data)
def remove_instances(self, instances):
for instance in instances:
# Remove the tool from the scene
tool = instance.transient_data["tool"]
if tool:
tool.Delete()
# Remove the collected CreatedInstance to remove from UI directly
self._remove_instance_from_context(instance)
def _imprint(self, tool, data):
# Save all data in a "openpype.{key}" = value data
# Instance id is the tool's name so we don't need to imprint as data
data.pop("instance_id", None)
active = data.pop("active", None)
if active is not None:
# Use active value to set the passthrough state
tool.SetAttrs({"TOOLB_PassThrough": not active})
for key, value in data.items():
tool.SetData(f"openpype.{key}", value)
def _update_tool_with_data(self, tool, data):
"""Update tool node name and output path based on product data"""
if "productName" not in data:
return
original_product_name = tool.GetData("openpype.productName")
original_format = tool.GetData(
"openpype.creator_attributes.image_format"
)
product_name = data["productName"]
if (
original_product_name != product_name
or tool.GetData("openpype.task") != data["task"]
or tool.GetData("openpype.folderPath") != data["folderPath"]
or original_format != data["creator_attributes"]["image_format"]
):
self._configure_saver_tool(data, tool, product_name)
def _configure_saver_tool(self, data, tool, product_name):
formatting_data = deepcopy(data)
# get frame padding from anatomy templates
frame_padding = self.project_anatomy.templates_obj.frame_padding
# get output format
ext = data["creator_attributes"]["image_format"]
# Product change detected
product_type = formatting_data["productType"]
f_product_name = formatting_data["productName"]
folder_path = formatting_data["folderPath"]
folder_name = folder_path.rsplit("/", 1)[-1]
# If the folder path and task do not match the current context then the
# workdir is not just the `AYON_WORKDIR`. Hence, we need to actually
# compute the resulting workdir
if (
data["folderPath"] == self.create_context.get_current_folder_path()
and data["task"] == self.create_context.get_current_task_name()
):
workdir = os.path.normpath(os.getenv("AYON_WORKDIR"))
else:
# TODO: Optimize this logic
project_name = self.create_context.get_current_project_name()
project_entity = get_project(project_name)
folder_entity = get_folder_by_path(project_name,
data["folderPath"])
task_entity = get_task_by_name(project_name,
folder_id=folder_entity["id"],
task_name=data["task"])
workdir = get_workdir(
project_entity=project_entity,
folder_entity=folder_entity,
task_entity=task_entity,
host_name=self.create_context.host_name,
)
formatting_data.update({
"workdir": workdir,
"frame": "0" * frame_padding,
"ext": ext,
"product": {
"name": f_product_name,
"type": product_type,
},
# TODO add more variants for 'folder' and 'task'
"folder": {
"name": folder_name,
},
"task": {
"name": data["task"],
},
# Backwards compatibility
"asset": folder_name,
"subset": f_product_name,
"family": product_type,
})
# build file path to render
# TODO make sure the keys are available in 'formatting_data'
temp_rendering_path_template = (
self.temp_rendering_path_template
.replace("{task}", "{task[name]}")
)
filepath = temp_rendering_path_template.format(**formatting_data)
comp = get_current_comp()
tool["Clip"] = comp.ReverseMapPath(os.path.normpath(filepath))
# Rename tool
if tool.Name != product_name:
print(f"Renaming {tool.Name} -> {product_name}")
tool.SetAttrs({"TOOLS_Name": product_name})
def get_managed_tool_data(self, tool):
"""Return data of the tool if it matches creator identifier"""
data = tool.GetData("openpype")
if not isinstance(data, dict):
return
if (
data.get("creator_identifier") != self.identifier
or data.get("id") not in {
AYON_INSTANCE_ID, AVALON_INSTANCE_ID
}
):
return
# Get active state from the actual tool state
attrs = tool.GetAttrs()
passthrough = attrs["TOOLB_PassThrough"]
data["active"] = not passthrough
# Override publisher's UUID generation because tool names are
# already unique in Fusion in a comp
data["instance_id"] = tool.Name
return data
def get_instance_attr_defs(self):
"""Settings for publish page"""
return self.get_pre_create_attr_defs()
def pass_pre_attributes_to_instance(self, instance_data, pre_create_data):
creator_attrs = instance_data["creator_attributes"] = {}
for pass_key in pre_create_data.keys():
creator_attrs[pass_key] = pre_create_data[pass_key]
def _get_render_target_enum(self):
rendering_targets = {
"local": "Local machine rendering",
"frames": "Use existing frames",
}
if "farm_rendering" in self.instance_attributes:
rendering_targets["farm"] = "Farm rendering"
return EnumDef(
"render_target", items=rendering_targets, label="Render target"
)
def _get_reviewable_bool(self):
return BoolDef(
"review",
default=("reviewable" in self.instance_attributes),
label="Review",
)
def _get_image_format_enum(self):
image_format_options = ["exr", "tga", "tif", "png", "jpg"]
return EnumDef(
"image_format",
items=image_format_options,
default=self.image_format,
label="Output Image Format",
)

View file

@ -1,63 +0,0 @@
import os
import sys
from qtpy import QtCore
class PulseThread(QtCore.QThread):
no_response = QtCore.Signal()
def __init__(self, parent=None):
super(PulseThread, self).__init__(parent=parent)
def run(self):
app = getattr(sys.modules["__main__"], "app", None)
# Interval in milliseconds
interval = os.environ.get("AYON_FUSION_PULSE_INTERVAL", 1000)
while True:
if self.isInterruptionRequested():
return
# We don't need to call Test because PyRemoteObject of the app
# will actually fail to even resolve the Test function if it has
# gone down. So we can actually already just check by confirming
# the method is still getting resolved. (Optimization)
if app.Test is None:
self.no_response.emit()
self.msleep(interval)
class FusionPulse(QtCore.QObject):
"""A Timer that checks whether host app is still alive.
This checks whether the Fusion process is still active at a certain
interval. This is useful due to how Fusion runs its scripts. Each script
runs in its own environment and process (a `fusionscript` process each).
If Fusion would go down and we have a UI process running at the same time
then it can happen that the `fusionscript.exe` will remain running in the
background in limbo due to e.g. a Qt interface's QApplication that keeps
running infinitely.
Warning:
When the host is not detected this will automatically exit
the current process.
"""
def __init__(self, parent=None):
super(FusionPulse, self).__init__(parent=parent)
self._thread = PulseThread(parent=self)
self._thread.no_response.connect(self.on_no_response)
def on_no_response(self):
print("Pulse detected no response from Fusion..")
sys.exit(1)
def start(self):
self._thread.start()
def stop(self):
self._thread.requestInterruption()

View file

@ -1,6 +0,0 @@
### AYON deploy MenuScripts
Note that this `MenuScripts` is not an official Fusion folder.
AYON only uses this folder in `{fusion}/deploy/` to trigger the AYON menu actions.
They are used in the actions defined in `.fu` files in `{fusion}/deploy/Config`.

View file

@ -1,29 +0,0 @@
# This is just a quick hack for users running Py3 locally but having no
# Qt library installed
import os
import subprocess
import importlib
try:
from qtpy import API_NAME
print(f"Qt binding: {API_NAME}")
mod = importlib.import_module(API_NAME)
print(f"Qt path: {mod.__file__}")
print("Qt library found, nothing to do..")
except ImportError:
print("Assuming no Qt library is installed..")
print('Installing PySide2 for Python 3.6: '
f'{os.environ["FUSION16_PYTHON36_HOME"]}')
# Get full path to python executable
exe = "python.exe" if os.name == 'nt' else "python"
python = os.path.join(os.environ["FUSION16_PYTHON36_HOME"], exe)
assert os.path.exists(python), f"Python doesn't exist: {python}"
# Do python -m pip install PySide2
args = [python, "-m", "pip", "install", "PySide2"]
print(f"Args: {args}")
subprocess.Popen(args)

View file

@ -1,47 +0,0 @@
import os
import sys
if sys.version_info < (3, 7):
# hack to handle discrepancy between distributed libraries and Python 3.6
# mostly because wrong version of urllib3
# TODO remove when not necessary
from ayon_fusion import FUSION_ADDON_ROOT
vendor_path = os.path.join(FUSION_ADDON_ROOT, "vendor")
if vendor_path not in sys.path:
sys.path.insert(0, vendor_path)
print(f"Added vendorized libraries from {vendor_path}")
from ayon_core.lib import Logger
from ayon_core.pipeline import (
install_host,
registered_host,
)
def main(env):
# This script working directory starts in Fusion application folder.
# However the contents of that folder can conflict with Qt library dlls
# so we make sure to move out of it to avoid DLL Load Failed errors.
os.chdir("..")
from ayon_fusion.api import FusionHost
from ayon_fusion.api import menu
# activate resolve from pype
install_host(FusionHost())
log = Logger.get_logger(__name__)
log.info(f"Registered host: {registered_host()}")
menu.launch_ayon_menu()
# Initiate a QTimer to check if Fusion is still alive every X interval
# If Fusion is not found - kill itself
# todo(roy): Implement timer that ensures UI doesn't remain when e.g.
# Fusion closes down
if __name__ == "__main__":
result = main(os.environ)
sys.exit(not bool(result))

View file

@ -1,60 +0,0 @@
{
Action
{
ID = "AYON_Menu",
Category = "AYON",
Name = "AYON Menu",
Targets =
{
Composition =
{
Execute = _Lua [=[
local scriptPath = app:MapPath("AYON:../MenuScripts/launch_menu.py")
if bmd.fileexists(scriptPath) == false then
print("[AYON Error] Can't run file: " .. scriptPath)
else
target:RunScript(scriptPath)
end
]=],
},
},
},
Action
{
ID = "AYON_Install_PySide2",
Category = "AYON",
Name = "Install PySide2",
Targets =
{
Composition =
{
Execute = _Lua [=[
local scriptPath = app:MapPath("AYON:../MenuScripts/install_pyside2.py")
if bmd.fileexists(scriptPath) == false then
print("[AYON Error] Can't run file: " .. scriptPath)
else
target:RunScript(scriptPath)
end
]=],
},
},
},
Menus
{
Target = "ChildFrame",
Before "Help"
{
Sub "AYON"
{
"AYON_Menu{}",
"_",
Sub "Admin" {
"AYON_Install_PySide2{}"
}
}
},
},
}

View file

@ -1,19 +0,0 @@
{
Locked = true,
Global = {
Paths = {
Map = {
["AYON:"] = "$(AYON_FUSION_ROOT)/deploy/ayon",
["Config:"] = "UserPaths:Config;AYON:Config",
["Scripts:"] = "UserPaths:Scripts;Reactor:System/Scripts",
},
},
Script = {
PythonVersion = 3,
Python3Forced = true
},
UserInterface = {
Language = "en_US"
},
},
}

View file

@ -1,36 +0,0 @@
import os
from ayon_applications import PreLaunchHook
from ayon_fusion import FUSION_ADDON_ROOT
class FusionLaunchMenuHook(PreLaunchHook):
"""Launch AYON menu on start of Fusion"""
app_groups = ["fusion"]
order = 9
def execute(self):
# Prelaunch hook is optional
settings = self.data["project_settings"][self.host_name]
if not settings["hooks"]["FusionLaunchMenuHook"]["enabled"]:
return
variant = self.application.name
if variant.isnumeric():
version = int(variant)
if version < 18:
print("Skipping launch of OpenPype menu on Fusion start "
"because Fusion version below 18.0 does not support "
"/execute argument on launch. "
f"Version detected: {version}")
return
else:
print(f"Application variant is not numeric: {variant}. "
"Validation for Fusion version 18+ for /execute "
"prelaunch argument skipped.")
path = os.path.join(FUSION_ADDON_ROOT,
"deploy",
"MenuScripts",
"launch_menu.py").replace("\\", "/")
script = f"fusion:RunScript('{path}')"
self.launch_context.launch_args.extend(["/execute", script])

View file

@ -1,169 +0,0 @@
import os
import shutil
import platform
from pathlib import Path
from ayon_fusion import (
FUSION_ADDON_ROOT,
FUSION_VERSIONS_DICT,
get_fusion_version,
)
from ayon_applications import (
PreLaunchHook,
LaunchTypes,
ApplicationLaunchFailed,
)
class FusionCopyPrefsPrelaunch(PreLaunchHook):
"""
Prepares local Fusion profile directory, copies existing Fusion profile.
This also sets FUSION MasterPrefs variable, which is used
to apply Master.prefs file to override some Fusion profile settings to:
- enable the AYON menu
- force Python 3 over Python 2
- force English interface
Master.prefs is defined in openpype/hosts/fusion/deploy/fusion_shared.prefs
"""
app_groups = {"fusion"}
order = 2
launch_types = {LaunchTypes.local}
def get_fusion_profile_name(self, profile_version) -> str:
# Returns 'Default', unless FUSION16_PROFILE is set
return os.getenv(f"FUSION{profile_version}_PROFILE", "Default")
def get_fusion_profile_dir(self, profile_version) -> Path:
# Get FUSION_PROFILE_DIR variable
fusion_profile = self.get_fusion_profile_name(profile_version)
fusion_var_prefs_dir = os.getenv(
f"FUSION{profile_version}_PROFILE_DIR"
)
# Check if FUSION_PROFILE_DIR exists
if fusion_var_prefs_dir and Path(fusion_var_prefs_dir).is_dir():
fu_prefs_dir = Path(fusion_var_prefs_dir, fusion_profile)
self.log.info(f"{fusion_var_prefs_dir} is set to {fu_prefs_dir}")
return fu_prefs_dir
def get_profile_source(self, profile_version) -> Path:
"""Get Fusion preferences profile location.
See Per-User_Preferences_and_Paths on VFXpedia for reference.
"""
fusion_profile = self.get_fusion_profile_name(profile_version)
profile_source = self.get_fusion_profile_dir(profile_version)
if profile_source:
return profile_source
# otherwise get default location of the profile folder
fu_prefs_dir = f"Blackmagic Design/Fusion/Profiles/{fusion_profile}"
if platform.system() == "Windows":
profile_source = Path(os.getenv("AppData"), fu_prefs_dir)
elif platform.system() == "Darwin":
profile_source = Path(
"~/Library/Application Support/", fu_prefs_dir
).expanduser()
elif platform.system() == "Linux":
profile_source = Path("~/.fusion", fu_prefs_dir).expanduser()
self.log.info(
f"Locating source Fusion prefs directory: {profile_source}"
)
return profile_source
def get_copy_fusion_prefs_settings(self):
# Get copy preferences options from the global application settings
copy_fusion_settings = self.data["project_settings"]["fusion"].get(
"copy_fusion_settings", {}
)
if not copy_fusion_settings:
self.log.error("Copy prefs settings not found")
copy_status = copy_fusion_settings.get("copy_status", False)
force_sync = copy_fusion_settings.get("force_sync", False)
copy_path = copy_fusion_settings.get("copy_path") or None
if copy_path:
copy_path = Path(copy_path).expanduser()
return copy_status, copy_path, force_sync
def copy_fusion_profile(
self, copy_from: Path, copy_to: Path, force_sync: bool
) -> None:
"""On the first Fusion launch copy the contents of Fusion profile
directory to the working predefined location. If the Openpype profile
folder exists, skip copying, unless re-sync is checked.
If the prefs were not copied on the first launch,
clean Fusion profile will be created in fu_profile_dir.
"""
if copy_to.exists() and not force_sync:
self.log.info(
"Destination Fusion preferences folder already exists: "
f"{copy_to} "
)
return
self.log.info("Starting copying Fusion preferences")
self.log.debug(f"force_sync option is set to {force_sync}")
try:
copy_to.mkdir(exist_ok=True, parents=True)
except PermissionError:
self.log.warning(f"Creating the folder not permitted at {copy_to}")
return
if not copy_from.exists():
self.log.warning(f"Fusion preferences not found in {copy_from}")
return
for file in copy_from.iterdir():
if file.suffix in (
".prefs",
".def",
".blocklist",
".fu",
".toolbars",
):
# convert Path to str to be compatible with Python 3.6+
shutil.copy(str(file), str(copy_to))
self.log.info(
f"Successfully copied preferences: {copy_from} to {copy_to}"
)
def execute(self):
(
copy_status,
fu_profile_dir,
force_sync,
) = self.get_copy_fusion_prefs_settings()
# Get launched application context and return correct app version
app_name = self.launch_context.env.get("AYON_APP_NAME")
app_version = get_fusion_version(app_name)
if app_version is None:
version_names = ", ".join(str(x) for x in FUSION_VERSIONS_DICT)
raise ApplicationLaunchFailed(
"Unable to detect valid Fusion version number from app "
f"name: {app_name}.\nMake sure to include at least a digit "
"to indicate the Fusion version like '18'.\n"
f"Detectable Fusion versions are: {version_names}"
)
_, profile_version = FUSION_VERSIONS_DICT[app_version]
fu_profile = self.get_fusion_profile_name(profile_version)
# do a copy of Fusion profile if copy_status toggle is enabled
if copy_status and fu_profile_dir is not None:
profile_source = self.get_profile_source(profile_version)
dest_folder = Path(fu_profile_dir, fu_profile)
self.copy_fusion_profile(profile_source, dest_folder, force_sync)
# Add temporary profile directory variables to customize Fusion
# to define where it can read custom scripts and tools from
fu_profile_dir_variable = f"FUSION{profile_version}_PROFILE_DIR"
self.log.info(f"Setting {fu_profile_dir_variable}: {fu_profile_dir}")
self.launch_context.env[fu_profile_dir_variable] = str(fu_profile_dir)
# Add custom Fusion Master Prefs and the temporary
# profile directory variables to customize Fusion
# to define where it can read custom scripts and tools from
master_prefs_variable = f"FUSION{profile_version}_MasterPrefs"
master_prefs = Path(
FUSION_ADDON_ROOT, "deploy", "ayon", "fusion_shared.prefs")
self.log.info(f"Setting {master_prefs_variable}: {master_prefs}")
self.launch_context.env[master_prefs_variable] = str(master_prefs)

View file

@ -1,71 +0,0 @@
import os
from ayon_applications import (
PreLaunchHook,
LaunchTypes,
ApplicationLaunchFailed,
)
from ayon_fusion import (
FUSION_ADDON_ROOT,
FUSION_VERSIONS_DICT,
get_fusion_version,
)
class FusionPrelaunch(PreLaunchHook):
"""
Prepares AYON Fusion environment.
Requires correct Python home variable to be defined in the environment
settings for Fusion to point at a valid Python 3 build for Fusion.
Python3 versions that are supported by Fusion:
Fusion 9, 16, 17 : Python 3.6
Fusion 18 : Python 3.6 - 3.10
"""
app_groups = {"fusion"}
order = 1
launch_types = {LaunchTypes.local}
def execute(self):
# making sure python 3 is installed at provided path
# Py 3.3-3.10 for Fusion 18+ or Py 3.6 for Fu 16-17
app_data = self.launch_context.env.get("AYON_APP_NAME")
app_version = get_fusion_version(app_data)
if not app_version:
raise ApplicationLaunchFailed(
"Fusion version information not found in System settings.\n"
"The key field in the 'applications/fusion/variants' should "
"consist a number, corresponding to major Fusion version."
)
py3_var, _ = FUSION_VERSIONS_DICT[app_version]
fusion_python3_home = self.launch_context.env.get(py3_var, "")
for path in fusion_python3_home.split(os.pathsep):
# Allow defining multiple paths, separated by os.pathsep,
# to allow "fallback" to other path.
# But make to set only a single path as final variable.
py3_dir = os.path.normpath(path)
if os.path.isdir(py3_dir):
break
else:
raise ApplicationLaunchFailed(
"Python 3 is not installed at the provided path.\n"
"Make sure the environment in fusion settings has "
"'FUSION_PYTHON3_HOME' set correctly and make sure "
"Python 3 is installed in the given path."
f"\n\nPYTHON PATH: {fusion_python3_home}"
)
self.log.info(f"Setting {py3_var}: '{py3_dir}'...")
self.launch_context.env[py3_var] = py3_dir
# Fusion 18+ requires FUSION_PYTHON3_HOME to also be on PATH
if app_version >= 18:
self.launch_context.env["PATH"] += os.pathsep + py3_dir
self.launch_context.env[py3_var] = py3_dir
# for hook installing PySide2
self.data["fusion_python3_home"] = py3_dir
self.log.info(f"Setting AYON_FUSION_ROOT: {FUSION_ADDON_ROOT}")
self.launch_context.env["AYON_FUSION_ROOT"] = FUSION_ADDON_ROOT

View file

@ -1,185 +0,0 @@
import os
import subprocess
import platform
import uuid
from ayon_applications import PreLaunchHook, LaunchTypes
class InstallPySideToFusion(PreLaunchHook):
"""Automatically installs Qt binding to fusion's python packages.
Check if fusion has installed PySide2 and will try to install if not.
For pipeline implementation is required to have Qt binding installed in
fusion's python packages.
"""
app_groups = {"fusion"}
order = 2
launch_types = {LaunchTypes.local}
def execute(self):
# Prelaunch hook is not crucial
try:
settings = self.data["project_settings"][self.host_name]
if not settings["hooks"]["InstallPySideToFusion"]["enabled"]:
return
self.inner_execute()
except Exception:
self.log.warning(
"Processing of {} crashed.".format(self.__class__.__name__),
exc_info=True
)
def inner_execute(self):
self.log.debug("Check for PySide2 installation.")
fusion_python3_home = self.data.get("fusion_python3_home")
if not fusion_python3_home:
self.log.warning("'fusion_python3_home' was not provided. "
"Installation of PySide2 not possible")
return
if platform.system().lower() == "windows":
exe_filenames = ["python.exe"]
else:
exe_filenames = ["python3", "python"]
for exe_filename in exe_filenames:
python_executable = os.path.join(fusion_python3_home, exe_filename)
if os.path.exists(python_executable):
break
if not os.path.exists(python_executable):
self.log.warning(
"Couldn't find python executable for fusion. {}".format(
python_executable
)
)
return
# Check if PySide2 is installed and skip if yes
if self._is_pyside_installed(python_executable):
self.log.debug("Fusion has already installed PySide2.")
return
self.log.debug("Installing PySide2.")
# Install PySide2 in fusion's python
if self._windows_require_permissions(
os.path.dirname(python_executable)):
result = self._install_pyside_windows(python_executable)
else:
result = self._install_pyside(python_executable)
if result:
self.log.info("Successfully installed PySide2 module to fusion.")
else:
self.log.warning("Failed to install PySide2 module to fusion.")
def _install_pyside_windows(self, python_executable):
"""Install PySide2 python module to fusion's python.
Installation requires administration rights that's why it is required
to use "pywin32" module which can execute command's and ask for
administration rights.
"""
try:
import win32con
import win32process
import win32event
import pywintypes
from win32comext.shell.shell import ShellExecuteEx
from win32comext.shell import shellcon
except Exception:
self.log.warning("Couldn't import \"pywin32\" modules")
return False
try:
# Parameters
# - use "-m pip" as module pip to install PySide2 and argument
# "--ignore-installed" is to force install module to fusion's
# site-packages and make sure it is binary compatible
parameters = "-m pip install --ignore-installed PySide2"
# Execute command and ask for administrator's rights
process_info = ShellExecuteEx(
nShow=win32con.SW_SHOWNORMAL,
fMask=shellcon.SEE_MASK_NOCLOSEPROCESS,
lpVerb="runas",
lpFile=python_executable,
lpParameters=parameters,
lpDirectory=os.path.dirname(python_executable)
)
process_handle = process_info["hProcess"]
win32event.WaitForSingleObject(process_handle,
win32event.INFINITE)
returncode = win32process.GetExitCodeProcess(process_handle)
return returncode == 0
except pywintypes.error:
return False
def _install_pyside(self, python_executable):
"""Install PySide2 python module to fusion's python."""
try:
# Parameters
# - use "-m pip" as module pip to install PySide2 and argument
# "--ignore-installed" is to force install module to fusion's
# site-packages and make sure it is binary compatible
env = dict(os.environ)
del env['PYTHONPATH']
args = [
python_executable,
"-m",
"pip",
"install",
"--ignore-installed",
"PySide2",
]
process = subprocess.Popen(
args, stdout=subprocess.PIPE, universal_newlines=True,
env=env
)
process.communicate()
return process.returncode == 0
except PermissionError:
self.log.warning(
"Permission denied with command:"
"\"{}\".".format(" ".join(args))
)
except OSError as error:
self.log.warning(f"OS error has occurred: \"{error}\".")
except subprocess.SubprocessError:
pass
def _is_pyside_installed(self, python_executable):
"""Check if PySide2 module is in fusion's pip list."""
args = [python_executable, "-c", "from qtpy import QtWidgets"]
process = subprocess.Popen(args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
_, stderr = process.communicate()
stderr = stderr.decode()
if stderr:
return False
return True
def _windows_require_permissions(self, dirpath):
if platform.system().lower() != "windows":
return False
try:
# Attempt to create a temporary file in the folder
temp_file_path = os.path.join(dirpath, uuid.uuid4().hex)
with open(temp_file_path, "w"):
pass
os.remove(temp_file_path) # Clean up temporary file
return False
except PermissionError:
return True
except BaseException as exc:
print(("Failed to determine if root requires permissions."
"Unexpected error: {}").format(exc))
return False

View file

@ -1,63 +0,0 @@
from ayon_core.lib import NumberDef
from ayon_fusion.api.plugin import GenericCreateSaver
class CreateImageSaver(GenericCreateSaver):
"""Fusion Saver to generate single image.
Created to explicitly separate single ('image') or
multi frame('render) outputs.
This might be temporary creator until 'alias' functionality will be
implemented to limit creation of additional product types with similar, but
not the same workflows.
"""
identifier = "io.openpype.creators.fusion.imagesaver"
label = "Image (saver)"
name = "image"
product_type = "image"
description = "Fusion Saver to generate image"
default_frame = 0
def get_detail_description(self):
return """Fusion Saver to generate single image.
This creator is expected for publishing of single frame `image` product
type.
Artist should provide frame number (integer) to specify which frame
should be published. It must be inside of global timeline frame range.
Supports local and deadline rendering.
Supports selection from predefined set of output file extensions:
- exr
- tga
- png
- tif
- jpg
Created to explicitly separate single frame ('image') or
multi frame ('render') outputs.
"""
def get_pre_create_attr_defs(self):
"""Settings for create page"""
attr_defs = [
self._get_render_target_enum(),
self._get_reviewable_bool(),
self._get_frame_int(),
self._get_image_format_enum(),
]
return attr_defs
def _get_frame_int(self):
return NumberDef(
"frame",
default=self.default_frame,
label="Frame",
tooltip="Set frame to be rendered, must be inside of global "
"timeline range"
)

View file

@ -1,149 +0,0 @@
from ayon_core.lib import (
UILabelDef,
NumberDef,
EnumDef
)
from ayon_fusion.api.plugin import GenericCreateSaver
from ayon_fusion.api.lib import get_current_comp
class CreateSaver(GenericCreateSaver):
"""Fusion Saver to generate image sequence of 'render' product type.
Original Saver creator targeted for 'render' product type. It uses
original not to descriptive name because of values in Settings.
"""
identifier = "io.openpype.creators.fusion.saver"
label = "Render (saver)"
name = "render"
product_type = "render"
description = "Fusion Saver to generate image sequence"
default_frame_range_option = "current_folder"
def get_detail_description(self):
return """Fusion Saver to generate image sequence.
This creator is expected for publishing of image sequences for 'render'
product type. (But can publish even single frame 'render'.)
Select what should be source of render range:
- "Current Folder context" - values set on folder on AYON server
- "From render in/out" - from node itself
- "From composition timeline" - from timeline
Supports local and farm rendering.
Supports selection from predefined set of output file extensions:
- exr
- tga
- png
- tif
- jpg
"""
def get_pre_create_attr_defs(self):
"""Settings for create page"""
attr_defs = [
self._get_render_target_enum(),
self._get_reviewable_bool(),
self._get_frame_range_enum(),
self._get_image_format_enum(),
*self._get_custom_frame_range_attribute_defs()
]
return attr_defs
def _get_frame_range_enum(self):
frame_range_options = {
"current_folder": "Current Folder context",
"render_range": "From render in/out",
"comp_range": "From composition timeline",
"custom_range": "Custom frame range",
}
return EnumDef(
"frame_range_source",
items=frame_range_options,
label="Frame range source",
default=self.default_frame_range_option
)
@staticmethod
def _get_custom_frame_range_attribute_defs() -> list:
# Define custom frame range defaults based on current comp
# timeline settings (if a comp is currently open)
comp = get_current_comp()
if comp is not None:
attrs = comp.GetAttrs()
frame_defaults = {
"frameStart": int(attrs["COMPN_GlobalStart"]),
"frameEnd": int(attrs["COMPN_GlobalEnd"]),
"handleStart": int(
attrs["COMPN_RenderStart"] - attrs["COMPN_GlobalStart"]
),
"handleEnd": int(
attrs["COMPN_GlobalEnd"] - attrs["COMPN_RenderEnd"]
),
}
else:
frame_defaults = {
"frameStart": 1001,
"frameEnd": 1100,
"handleStart": 0,
"handleEnd": 0
}
return [
UILabelDef(
label="<br><b>Custom Frame Range</b><br>"
"<i>only used with 'Custom frame range' source</i>"
),
NumberDef(
"custom_frameStart",
label="Frame Start",
default=frame_defaults["frameStart"],
minimum=0,
decimals=0,
tooltip=(
"Set the start frame for the export.\n"
"Only used if frame range source is 'Custom frame range'."
)
),
NumberDef(
"custom_frameEnd",
label="Frame End",
default=frame_defaults["frameEnd"],
minimum=0,
decimals=0,
tooltip=(
"Set the end frame for the export.\n"
"Only used if frame range source is 'Custom frame range'."
)
),
NumberDef(
"custom_handleStart",
label="Handle Start",
default=frame_defaults["handleStart"],
minimum=0,
decimals=0,
tooltip=(
"Set the start handles for the export, this will be "
"added before the start frame.\n"
"Only used if frame range source is 'Custom frame range'."
)
),
NumberDef(
"custom_handleEnd",
label="Handle End",
default=frame_defaults["handleEnd"],
minimum=0,
decimals=0,
tooltip=(
"Set the end handles for the export, this will be added "
"after the end frame.\n"
"Only used if frame range source is 'Custom frame range'."
)
)
]

View file

@ -1,132 +0,0 @@
import ayon_api
from ayon_fusion.api import (
get_current_comp
)
from ayon_core.pipeline import (
AutoCreator,
CreatedInstance,
)
class FusionWorkfileCreator(AutoCreator):
identifier = "workfile"
product_type = "workfile"
label = "Workfile"
icon = "fa5.file"
default_variant = "Main"
create_allow_context_change = False
data_key = "openpype_workfile"
def collect_instances(self):
comp = get_current_comp()
data = comp.GetData(self.data_key)
if not data:
return
product_name = data.get("productName")
if product_name is None:
product_name = data["subset"]
instance = CreatedInstance(
product_type=self.product_type,
product_name=product_name,
data=data,
creator=self
)
instance.transient_data["comp"] = comp
self._add_instance_to_context(instance)
def update_instances(self, update_list):
for created_inst, _changes in update_list:
comp = created_inst.transient_data["comp"]
if not hasattr(comp, "SetData"):
# Comp is not alive anymore, likely closed by the user
self.log.error("Workfile comp not found for existing instance."
" Comp might have been closed in the meantime.")
continue
# Imprint data into the comp
data = created_inst.data_to_store()
comp.SetData(self.data_key, data)
def create(self, options=None):
comp = get_current_comp()
if not comp:
self.log.error("Unable to find current comp")
return
existing_instance = None
for instance in self.create_context.instances:
if instance.product_type == self.product_type:
existing_instance = instance
break
project_name = self.create_context.get_current_project_name()
folder_path = self.create_context.get_current_folder_path()
task_name = self.create_context.get_current_task_name()
host_name = self.create_context.host_name
existing_folder_path = None
if existing_instance is not None:
existing_folder_path = existing_instance["folderPath"]
if existing_instance is None:
folder_entity = ayon_api.get_folder_by_path(
project_name, folder_path
)
task_entity = ayon_api.get_task_by_name(
project_name, folder_entity["id"], task_name
)
product_name = self.get_product_name(
project_name,
folder_entity,
task_entity,
self.default_variant,
host_name,
)
data = {
"folderPath": folder_path,
"task": task_name,
"variant": self.default_variant,
}
data.update(self.get_dynamic_data(
project_name,
folder_entity,
task_entity,
self.default_variant,
host_name,
None
))
new_instance = CreatedInstance(
self.product_type, product_name, data, self
)
new_instance.transient_data["comp"] = comp
self._add_instance_to_context(new_instance)
elif (
existing_folder_path != folder_path
or existing_instance["task"] != task_name
):
folder_entity = ayon_api.get_folder_by_path(
project_name, folder_path
)
task_entity = ayon_api.get_task_by_name(
project_name, folder_entity["id"], task_name
)
product_name = self.get_product_name(
project_name,
folder_entity,
task_entity,
self.default_variant,
host_name,
)
existing_instance["folderPath"] = folder_path
existing_instance["task"] = task_name
existing_instance["productName"] = product_name

View file

@ -1,27 +0,0 @@
from ayon_core.pipeline import InventoryAction
class FusionSelectContainers(InventoryAction):
label = "Select Containers"
icon = "mouse-pointer"
color = "#d8d8d8"
def process(self, containers):
from ayon_fusion.api import (
get_current_comp,
comp_lock_and_undo_chunk
)
tools = [i["_tool"] for i in containers]
comp = get_current_comp()
flow = comp.CurrentFrame.FlowView
with comp_lock_and_undo_chunk(comp, self.label):
# Clear selection
flow.Select()
# Select tool
for tool in tools:
flow.Select(tool)

View file

@ -1,72 +0,0 @@
from qtpy import QtGui, QtWidgets
from ayon_core.pipeline import InventoryAction
from ayon_core import style
from ayon_fusion.api import (
get_current_comp,
comp_lock_and_undo_chunk
)
class FusionSetToolColor(InventoryAction):
"""Update the color of the selected tools"""
label = "Set Tool Color"
icon = "plus"
color = "#d8d8d8"
_fallback_color = QtGui.QColor(1.0, 1.0, 1.0)
def process(self, containers):
"""Color all selected tools the selected colors"""
result = []
comp = get_current_comp()
# Get tool color
first = containers[0]
tool = first["_tool"]
color = tool.TileColor
if color is not None:
qcolor = QtGui.QColor().fromRgbF(color["R"], color["G"], color["B"])
else:
qcolor = self._fallback_color
# Launch pick color
picked_color = self.get_color_picker(qcolor)
if not picked_color:
return
with comp_lock_and_undo_chunk(comp):
for container in containers:
# Convert color to RGB 0-1 floats
rgb_f = picked_color.getRgbF()
rgb_f_table = {"R": rgb_f[0], "G": rgb_f[1], "B": rgb_f[2]}
# Update tool
tool = container["_tool"]
tool.TileColor = rgb_f_table
result.append(container)
return result
def get_color_picker(self, color):
"""Launch color picker and return chosen color
Args:
color(QtGui.QColor): Start color to display
Returns:
QtGui.QColor
"""
color_dialog = QtWidgets.QColorDialog(color)
color_dialog.setStyleSheet(style.load_stylesheet())
accepted = color_dialog.exec_()
if not accepted:
return
return color_dialog.selectedColor()

View file

@ -1,81 +0,0 @@
"""A module containing generic loader actions that will display in the Loader.
"""
from ayon_core.pipeline import load
class FusionSetFrameRangeLoader(load.LoaderPlugin):
"""Set frame range excluding pre- and post-handles"""
product_types = {
"animation",
"camera",
"imagesequence",
"render",
"yeticache",
"pointcache",
"render",
}
representations = {"*"}
extensions = {"*"}
label = "Set frame range"
order = 11
icon = "clock-o"
color = "white"
def load(self, context, name, namespace, data):
from ayon_fusion.api import lib
version_attributes = context["version"]["attrib"]
start = version_attributes.get("frameStart", None)
end = version_attributes.get("frameEnd", None)
if start is None or end is None:
print("Skipping setting frame range because start or "
"end frame data is missing..")
return
lib.update_frame_range(start, end)
class FusionSetFrameRangeWithHandlesLoader(load.LoaderPlugin):
"""Set frame range including pre- and post-handles"""
product_types = {
"animation",
"camera",
"imagesequence",
"render",
"yeticache",
"pointcache",
"render",
}
representations = {"*"}
label = "Set frame range (with handles)"
order = 12
icon = "clock-o"
color = "white"
def load(self, context, name, namespace, data):
from ayon_fusion.api import lib
version_attributes = context["version"]["attrib"]
start = version_attributes.get("frameStart", None)
end = version_attributes.get("frameEnd", None)
if start is None or end is None:
print("Skipping setting frame range because start or "
"end frame data is missing..")
return
# Include handles
start -= version_attributes.get("handleStart", 0)
end += version_attributes.get("handleEnd", 0)
lib.update_frame_range(start, end)

View file

@ -1,72 +0,0 @@
from ayon_core.pipeline import (
load,
get_representation_path,
)
from ayon_fusion.api import (
imprint_container,
get_current_comp,
comp_lock_and_undo_chunk
)
class FusionLoadAlembicMesh(load.LoaderPlugin):
"""Load Alembic mesh into Fusion"""
product_types = {"pointcache", "model"}
representations = {"*"}
extensions = {"abc"}
label = "Load alembic mesh"
order = -10
icon = "code-fork"
color = "orange"
tool_type = "SurfaceAlembicMesh"
def load(self, context, name, namespace, data):
# Fallback to folder name when namespace is None
if namespace is None:
namespace = context["folder"]["name"]
# Create the Loader with the filename path set
comp = get_current_comp()
with comp_lock_and_undo_chunk(comp, "Create tool"):
path = self.filepath_from_context(context)
args = (-32768, -32768)
tool = comp.AddTool(self.tool_type, *args)
tool["Filename"] = path
imprint_container(tool,
name=name,
namespace=namespace,
context=context,
loader=self.__class__.__name__)
def switch(self, container, context):
self.update(container, context)
def update(self, container, context):
"""Update Alembic path"""
tool = container["_tool"]
assert tool.ID == self.tool_type, f"Must be {self.tool_type}"
comp = tool.Comp()
repre_entity = context["representation"]
path = get_representation_path(repre_entity)
with comp_lock_and_undo_chunk(comp, "Update tool"):
tool["Filename"] = path
# Update the imprinted representation
tool.SetData("avalon.representation", repre_entity["id"])
def remove(self, container):
tool = container["_tool"]
assert tool.ID == self.tool_type, f"Must be {self.tool_type}"
comp = tool.Comp()
with comp_lock_and_undo_chunk(comp, "Remove tool"):
tool.Delete()

View file

@ -1,87 +0,0 @@
from ayon_core.pipeline import (
load,
get_representation_path,
)
from ayon_fusion.api import (
imprint_container,
get_current_comp,
comp_lock_and_undo_chunk,
)
class FusionLoadFBXMesh(load.LoaderPlugin):
"""Load FBX mesh into Fusion"""
product_types = {"*"}
representations = {"*"}
extensions = {
"3ds",
"amc",
"aoa",
"asf",
"bvh",
"c3d",
"dae",
"dxf",
"fbx",
"htr",
"mcd",
"obj",
"trc",
}
label = "Load FBX mesh"
order = -10
icon = "code-fork"
color = "orange"
tool_type = "SurfaceFBXMesh"
def load(self, context, name, namespace, data):
# Fallback to folder name when namespace is None
if namespace is None:
namespace = context["folder"]["name"]
# Create the Loader with the filename path set
comp = get_current_comp()
with comp_lock_and_undo_chunk(comp, "Create tool"):
path = self.filepath_from_context(context)
args = (-32768, -32768)
tool = comp.AddTool(self.tool_type, *args)
tool["ImportFile"] = path
imprint_container(
tool,
name=name,
namespace=namespace,
context=context,
loader=self.__class__.__name__,
)
def switch(self, container, context):
self.update(container, context)
def update(self, container, context):
"""Update path"""
tool = container["_tool"]
assert tool.ID == self.tool_type, f"Must be {self.tool_type}"
comp = tool.Comp()
repre_entity = context["representation"]
path = get_representation_path(repre_entity)
with comp_lock_and_undo_chunk(comp, "Update tool"):
tool["ImportFile"] = path
# Update the imprinted representation
tool.SetData("avalon.representation", repre_entity["id"])
def remove(self, container):
tool = container["_tool"]
assert tool.ID == self.tool_type, f"Must be {self.tool_type}"
comp = tool.Comp()
with comp_lock_and_undo_chunk(comp, "Remove tool"):
tool.Delete()

View file

@ -1,291 +0,0 @@
import contextlib
import ayon_core.pipeline.load as load
from ayon_fusion.api import (
imprint_container,
get_current_comp,
comp_lock_and_undo_chunk,
)
from ayon_core.lib.transcoding import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS
comp = get_current_comp()
@contextlib.contextmanager
def preserve_inputs(tool, inputs):
"""Preserve the tool's inputs after context"""
comp = tool.Comp()
values = {}
for name in inputs:
tool_input = getattr(tool, name)
value = tool_input[comp.TIME_UNDEFINED]
values[name] = value
try:
yield
finally:
for name, value in values.items():
tool_input = getattr(tool, name)
tool_input[comp.TIME_UNDEFINED] = value
@contextlib.contextmanager
def preserve_trim(loader, log=None):
"""Preserve the relative trim of the Loader tool.
This tries to preserve the loader's trim (trim in and trim out) after
the context by reapplying the "amount" it trims on the clip's length at
start and end.
"""
# Get original trim as amount of "trimming" from length
time = loader.Comp().TIME_UNDEFINED
length = loader.GetAttrs()["TOOLIT_Clip_Length"][1] - 1
trim_from_start = loader["ClipTimeStart"][time]
trim_from_end = length - loader["ClipTimeEnd"][time]
try:
yield
finally:
length = loader.GetAttrs()["TOOLIT_Clip_Length"][1] - 1
if trim_from_start > length:
trim_from_start = length
if log:
log.warning(
"Reducing trim in to %d "
"(because of less frames)" % trim_from_start
)
remainder = length - trim_from_start
if trim_from_end > remainder:
trim_from_end = remainder
if log:
log.warning(
"Reducing trim in to %d "
"(because of less frames)" % trim_from_end
)
loader["ClipTimeStart"][time] = trim_from_start
loader["ClipTimeEnd"][time] = length - trim_from_end
def loader_shift(loader, frame, relative=True):
"""Shift global in time by i preserving duration
This moves the loader by i frames preserving global duration. When relative
is False it will shift the global in to the start frame.
Args:
loader (tool): The fusion loader tool.
frame (int): The amount of frames to move.
relative (bool): When True the shift is relative, else the shift will
change the global in to frame.
Returns:
int: The resulting relative frame change (how much it moved)
"""
comp = loader.Comp()
time = comp.TIME_UNDEFINED
old_in = loader["GlobalIn"][time]
old_out = loader["GlobalOut"][time]
if relative:
shift = frame
else:
shift = frame - old_in
if not shift:
return 0
# Shifting global in will try to automatically compensate for the change
# in the "ClipTimeStart" and "HoldFirstFrame" inputs, so we preserve those
# input values to "just shift" the clip
with preserve_inputs(
loader,
inputs=[
"ClipTimeStart",
"ClipTimeEnd",
"HoldFirstFrame",
"HoldLastFrame",
],
):
# GlobalIn cannot be set past GlobalOut or vice versa
# so we must apply them in the order of the shift.
if shift > 0:
loader["GlobalOut"][time] = old_out + shift
loader["GlobalIn"][time] = old_in + shift
else:
loader["GlobalIn"][time] = old_in + shift
loader["GlobalOut"][time] = old_out + shift
return int(shift)
class FusionLoadSequence(load.LoaderPlugin):
"""Load image sequence into Fusion"""
product_types = {
"imagesequence",
"review",
"render",
"plate",
"image",
"online",
}
representations = {"*"}
extensions = set(
ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS)
)
label = "Load sequence"
order = -10
icon = "code-fork"
color = "orange"
def load(self, context, name, namespace, data):
# Fallback to folder name when namespace is None
if namespace is None:
namespace = context["folder"]["name"]
# Use the first file for now
path = self.filepath_from_context(context)
# Create the Loader with the filename path set
comp = get_current_comp()
with comp_lock_and_undo_chunk(comp, "Create Loader"):
args = (-32768, -32768)
tool = comp.AddTool("Loader", *args)
tool["Clip"] = comp.ReverseMapPath(path)
# Set global in point to start frame (if in version.data)
start = self._get_start(context["version"], tool)
loader_shift(tool, start, relative=False)
imprint_container(
tool,
name=name,
namespace=namespace,
context=context,
loader=self.__class__.__name__,
)
def switch(self, container, context):
self.update(container, context)
def update(self, container, context):
"""Update the Loader's path
Fusion automatically tries to reset some variables when changing
the loader's path to a new file. These automatic changes are to its
inputs:
- ClipTimeStart: Fusion reset to 0 if duration changes
- We keep the trim in as close as possible to the previous value.
When there are less frames then the amount of trim we reduce
it accordingly.
- ClipTimeEnd: Fusion reset to 0 if duration changes
- We keep the trim out as close as possible to the previous value
within new amount of frames after trim in (ClipTimeStart) has
been set.
- GlobalIn: Fusion reset to comp's global in if duration changes
- We change it to the "frameStart"
- GlobalEnd: Fusion resets to globalIn + length if duration changes
- We do the same like Fusion - allow fusion to take control.
- HoldFirstFrame: Fusion resets this to 0
- We preserve the value.
- HoldLastFrame: Fusion resets this to 0
- We preserve the value.
- Reverse: Fusion resets to disabled if "Loop" is not enabled.
- We preserve the value.
- Depth: Fusion resets to "Format"
- We preserve the value.
- KeyCode: Fusion resets to ""
- We preserve the value.
- TimeCodeOffset: Fusion resets to 0
- We preserve the value.
"""
tool = container["_tool"]
assert tool.ID == "Loader", "Must be Loader"
comp = tool.Comp()
repre_entity = context["representation"]
path = self.filepath_from_context(context)
# Get start frame from version data
start = self._get_start(context["version"], tool)
with comp_lock_and_undo_chunk(comp, "Update Loader"):
# Update the loader's path whilst preserving some values
with preserve_trim(tool, log=self.log):
with preserve_inputs(
tool,
inputs=(
"HoldFirstFrame",
"HoldLastFrame",
"Reverse",
"Depth",
"KeyCode",
"TimeCodeOffset",
),
):
tool["Clip"] = comp.ReverseMapPath(path)
# Set the global in to the start frame of the sequence
global_in_changed = loader_shift(tool, start, relative=False)
if global_in_changed:
# Log this change to the user
self.log.debug(
"Changed '%s' global in: %d" % (tool.Name, start)
)
# Update the imprinted representation
tool.SetData("avalon.representation", repre_entity["id"])
def remove(self, container):
tool = container["_tool"]
assert tool.ID == "Loader", "Must be Loader"
comp = tool.Comp()
with comp_lock_and_undo_chunk(comp, "Remove Loader"):
tool.Delete()
def _get_start(self, version_entity, tool):
"""Return real start frame of published files (incl. handles)"""
attributes = version_entity["attrib"]
# Get start frame directly with handle if it's in data
start = attributes.get("frameStartHandle")
if start is not None:
return start
# Get frame start without handles
start = attributes.get("frameStart")
if start is None:
self.log.warning(
"Missing start frame for version "
"assuming starts at frame 0 for: "
"{}".format(tool.Name)
)
return 0
# Use `handleStart` if the data is available
handle_start = attributes.get("handleStart")
if handle_start:
start -= handle_start
return start

View file

@ -1,87 +0,0 @@
from ayon_core.pipeline import (
load,
get_representation_path,
)
from ayon_fusion.api import (
imprint_container,
get_current_comp,
comp_lock_and_undo_chunk
)
from ayon_fusion.api.lib import get_fusion_module
class FusionLoadUSD(load.LoaderPlugin):
"""Load USD into Fusion
Support for USD was added since Fusion 18.5
"""
product_types = {"*"}
representations = {"*"}
extensions = {"usd", "usda", "usdz"}
label = "Load USD"
order = -10
icon = "code-fork"
color = "orange"
tool_type = "uLoader"
@classmethod
def apply_settings(cls, project_settings):
super(FusionLoadUSD, cls).apply_settings(project_settings)
if cls.enabled:
# Enable only in Fusion 18.5+
fusion = get_fusion_module()
version = fusion.GetVersion()
major = version[1]
minor = version[2]
is_usd_supported = (major, minor) >= (18, 5)
cls.enabled = is_usd_supported
def load(self, context, name, namespace, data):
# Fallback to folder name when namespace is None
if namespace is None:
namespace = context["folder"]["name"]
# Create the Loader with the filename path set
comp = get_current_comp()
with comp_lock_and_undo_chunk(comp, "Create tool"):
path = self.fname
args = (-32768, -32768)
tool = comp.AddTool(self.tool_type, *args)
tool["Filename"] = path
imprint_container(tool,
name=name,
namespace=namespace,
context=context,
loader=self.__class__.__name__)
def switch(self, container, context):
self.update(container, context)
def update(self, container, context):
tool = container["_tool"]
assert tool.ID == self.tool_type, f"Must be {self.tool_type}"
comp = tool.Comp()
repre_entity = context["representation"]
path = get_representation_path(repre_entity)
with comp_lock_and_undo_chunk(comp, "Update tool"):
tool["Filename"] = path
# Update the imprinted representation
tool.SetData("avalon.representation", repre_entity["id"])
def remove(self, container):
tool = container["_tool"]
assert tool.ID == self.tool_type, f"Must be {self.tool_type}"
comp = tool.Comp()
with comp_lock_and_undo_chunk(comp, "Remove tool"):
tool.Delete()

View file

@ -1,33 +0,0 @@
"""Import workfiles into your current comp.
As all imported nodes are free floating and will probably be changed there
is no update or reload function added for this plugin
"""
from ayon_core.pipeline import load
from ayon_fusion.api import (
get_current_comp,
get_bmd_library,
)
class FusionLoadWorkfile(load.LoaderPlugin):
"""Load the content of a workfile into Fusion"""
product_types = {"workfile"}
representations = {"*"}
extensions = {"comp"}
label = "Load Workfile"
order = -10
icon = "code-fork"
color = "orange"
def load(self, context, name, namespace, data):
# Get needed elements
bmd = get_bmd_library()
comp = get_current_comp()
path = self.filepath_from_context(context)
# Paste the content of the file into the current comp
comp.Paste(bmd.readfile(path))

View file

@ -1,22 +0,0 @@
import pyblish.api
from ayon_fusion.api import get_current_comp
class CollectCurrentCompFusion(pyblish.api.ContextPlugin):
"""Collect current comp"""
order = pyblish.api.CollectorOrder - 0.4
label = "Collect Current Comp"
hosts = ["fusion"]
def process(self, context):
"""Collect all image sequence tools"""
current_comp = get_current_comp()
assert current_comp, "Must have active Fusion composition"
context.data["currentComp"] = current_comp
# Store path to current file
filepath = current_comp.GetAttrs().get("COMPS_FileName", "")
context.data['currentFile'] = filepath

View file

@ -1,44 +0,0 @@
import pyblish.api
def get_comp_render_range(comp):
"""Return comp's start-end render range and global start-end range."""
comp_attrs = comp.GetAttrs()
start = comp_attrs["COMPN_RenderStart"]
end = comp_attrs["COMPN_RenderEnd"]
global_start = comp_attrs["COMPN_GlobalStart"]
global_end = comp_attrs["COMPN_GlobalEnd"]
# Whenever render ranges are undefined fall back
# to the comp's global start and end
if start == -1000000000:
start = global_start
if end == -1000000000:
end = global_end
return start, end, global_start, global_end
class CollectFusionCompFrameRanges(pyblish.api.ContextPlugin):
"""Collect current comp"""
# We run this after CollectorOrder - 0.1 otherwise it gets
# overridden by global plug-in `CollectContextEntities`
order = pyblish.api.CollectorOrder - 0.05
label = "Collect Comp Frame Ranges"
hosts = ["fusion"]
def process(self, context):
"""Collect all image sequence tools"""
comp = context.data["currentComp"]
# Store comp render ranges
start, end, global_start, global_end = get_comp_render_range(comp)
context.data.update({
"renderFrameStart": int(start),
"renderFrameEnd": int(end),
"compFrameStart": int(global_start),
"compFrameEnd": int(global_end)
})

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