diff --git a/openpype/hosts/equalizer/__init__.py b/openpype/hosts/equalizer/__init__.py new file mode 100644 index 00000000000..aafe1580be8 --- /dev/null +++ b/openpype/hosts/equalizer/__init__.py @@ -0,0 +1,9 @@ +from .addon import ( + EqualizerAddon, + EQUALIZER_HOST_DIR, +) + +__all__ = [ + "EqualizerAddon", + "EQUALIZER_HOST_DIR", +] diff --git a/openpype/hosts/equalizer/addon.py b/openpype/hosts/equalizer/addon.py new file mode 100644 index 00000000000..571b374a708 --- /dev/null +++ b/openpype/hosts/equalizer/addon.py @@ -0,0 +1,40 @@ +import os +from openpype.modules import OpenPypeModule, IHostAddon + +EQUALIZER_HOST_DIR = os.path.dirname(os.path.abspath(__file__)) + + +class EqualizerAddon(OpenPypeModule, IHostAddon): + name = "equalizer" + host_name = "equalizer" + heartbeat = 500 + + def initialize(self, module_settings): + self.heartbeat = module_settings.get("heartbeat_interval", 500) + self.enabled = True + + def add_implementation_envs(self, env, _app): + # 3dEqualizer utilize TDE4_ROOT for its root directory + # and PYTHON_CUSTOM_SCRIPTS_3DE4 as a colon separated list of + # directories to look for additional python scripts. + # (Windows: list is separated by semicolons). + # Ad + + startup_path = os.path.join(EQUALIZER_HOST_DIR, "startup") + if "PYTHON_CUSTOM_SCRIPTS_3DE4" in env: + startup_path = os.path.join( + env["PYTHON_CUSTOM_SCRIPTS_3DE4"], + startup_path) + + env["PYTHON_CUSTOM_SCRIPTS_3DE4"] = startup_path + env["AYON_TDE4_HEARTBEAT_INTERVAL"] = str(self.heartbeat) + + def get_launch_hook_paths(self, app): + if app.host_name != self.host_name: + return [] + return [ + os.path.join(EQUALIZER_HOST_DIR, "hooks") + ] + + def get_workfile_extensions(self): + return [".3de"] diff --git a/openpype/hosts/equalizer/api/__init__.py b/openpype/hosts/equalizer/api/__init__.py new file mode 100644 index 00000000000..992187d4637 --- /dev/null +++ b/openpype/hosts/equalizer/api/__init__.py @@ -0,0 +1,11 @@ +from .host import EqualizerHost +from .plugin import EqualizerCreator, ExtractScriptBase +from .pipeline import Container, maintained_model_selection + +__all__ = [ + "EqualizerHost", + "EqualizerCreator", + "Container", + "ExtractScriptBase", + "maintained_model_selection", +] diff --git a/openpype/hosts/equalizer/api/host.py b/openpype/hosts/equalizer/api/host.py new file mode 100644 index 00000000000..70438ef2a65 --- /dev/null +++ b/openpype/hosts/equalizer/api/host.py @@ -0,0 +1,192 @@ +"""3dequalizer host implementation. + +note: + 3dequalizer 7.1v2 uses Python 3.7.9 + +""" +import json +import os +import re + +import pyblish.api +import tde4 # noqa: F401 +from attrs import asdict +from attrs.exceptions import NotAnAttrsClassError +from qtpy import QtCore, QtWidgets + +from openpype.host import HostBase, ILoadHost, IPublishHost, IWorkfileHost +from openpype.hosts.equalizer import EQUALIZER_HOST_DIR +from openpype.hosts.equalizer.api.pipeline import Container +from openpype.pipeline import ( + register_creator_plugin_path, + register_loader_plugin_path, +) + +CONTEXT_REGEX = re.compile( + r"AYON_CONTEXT::(?P.*?)::AYON_CONTEXT_END", + re.DOTALL) +PLUGINS_DIR = os.path.join(EQUALIZER_HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") + + +class EqualizerHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): + name = "equalizer" + _instance = None + + def __new__(cls): + # singleton - ensure only one instance of the host is created. + # This is necessary because 3DEqualizer doesn't have a way to + # store custom data, so we need to store it in the project notes. + if not hasattr(cls, "_instance") or not cls._instance: + cls._instance = super(EqualizerHost, cls).__new__(cls) + return cls._instance + + def __init__(self): + self._qapp = None + super(EqualizerHost, self).__init__() + + def workfile_has_unsaved_changes(self): + """Return the state of the current workfile. + + 3DEqualizer returns state as 1 or zero, so we need to invert it. + + Returns: + bool: True if the current workfile has unsaved changes. + """ + return not bool(tde4.isProjectUpToDate()) + + def get_workfile_extensions(self): + return [".3de"] + + def save_workfile(self, dst_path=None): + if not dst_path: + dst_path = tde4.getProjectPath() + result = tde4.saveProject(dst_path, True) + if not bool(result): + raise RuntimeError(f"Failed to save workfile {dst_path}.") + + return dst_path + + def open_workfile(self, filepath): + result = tde4.loadProject(filepath, True) + if not bool(result): + raise RuntimeError(f"Failed to open workfile {filepath}.") + + return filepath + + def get_current_workfile(self): + return tde4.getProjectPath() + + def get_containers(self): + context = self.get_context_data() + if context: + return context.get("containers", []) + return [] + + def add_container(self, container: Container): + context_data = self.get_context_data() + containers = self.get_containers() + + for _container in containers: + if _container["name"] == container.name and _container["namespace"] == container.namespace: # noqa: E501 + containers.remove(_container) + break + + try: + containers.append(asdict(container)) + except NotAnAttrsClassError: + print("not an attrs class") + containers.append(container) + + context_data["containers"] = containers + self.update_context_data(context_data, changes={}) + + def get_context_data(self) -> dict: + """Get context data from the current workfile. + + 3Dequalizer doesn't have any custom node or other + place to store metadata, so we store context data in + the project notes encoded as JSON and wrapped in a + special guard string `AYON_CONTEXT::...::AYON_CONTEXT_END`. + + Returns: + dict: Context data. + """ + + # sourcery skip: use-named-expression + m = re.search(CONTEXT_REGEX, tde4.getProjectNotes()) + try: + context = json.loads(m["context"]) if m else {} + except ValueError: + self.log.debug("context data is not valid json") + context = {} + + return context + + def update_context_data(self, data, changes): + """Update context data in the current workfile. + + Serialize context data as json and store it in the + project notes. If the context data is not found, create + a placeholder there. See `get_context_data` for more info. + + Args: + data (dict): Context data. + changes (dict): Changes to the context data. + + Raises: + RuntimeError: If the context data is not found. + """ + notes = tde4.getProjectNotes() + m = re.search(CONTEXT_REGEX, notes) + if not m: + # context data not found, create empty placeholder + tde4.setProjectNotes( + f"{tde4.getProjectNotes()}\n" + f"AYON_CONTEXT::::AYON_CONTEXT_END\n") + + original_data = self.get_context_data() + + updated_data = original_data.copy() + updated_data.update(data) + update_str = json.dumps(updated_data or {}, indent=4) + + tde4.setProjectNotes( + re.sub( + CONTEXT_REGEX, + f"AYON_CONTEXT::{update_str}::AYON_CONTEXT_END", + tde4.getProjectNotes() + ) + ) + tde4.updateGUI() + + def install(self): + if not QtCore.QCoreApplication.instance(): + app = QtWidgets.QApplication([]) + self._qapp = app + self._qapp.setQuitOnLastWindowClosed(False) + + pyblish.api.register_host("equalizer") + + pyblish.api.register_plugin_path(PUBLISH_PATH) + register_loader_plugin_path(LOAD_PATH) + register_creator_plugin_path(CREATE_PATH) + + heartbeat_interval = os.getenv("AYON_TDE4_HEARTBEAT_INTERVAL") or 500 + tde4.setTimerCallbackFunction( + "EqualizerHost._timer", int(heartbeat_interval)) + + @staticmethod + def _timer(): + QtWidgets.QApplication.instance().processEvents( + QtCore.QEventLoop.AllEvents) + + @classmethod + def get_host(cls): + return cls._instance + + def get_main_window(self): + return self._qapp.activeWindow() diff --git a/openpype/hosts/equalizer/api/pipeline.py b/openpype/hosts/equalizer/api/pipeline.py new file mode 100644 index 00000000000..b1d785f41ff --- /dev/null +++ b/openpype/hosts/equalizer/api/pipeline.py @@ -0,0 +1,39 @@ +from attrs import field, define +from openpype.pipeline import AVALON_CONTAINER_ID +import contextlib +import tde4 + + +@define +class Container(object): + + name: str = field(default=None) + id: str = field(init=False, default=AVALON_CONTAINER_ID) + namespace: str = field(default="") + loader: str = field(default=None) + representation: str = field(default=None) + + +@contextlib.contextmanager +def maintained_model_selection(): + """Maintain model selection during context.""" + + point_groups = tde4.getPGroupList() + point_group = next( + ( + pg for pg in point_groups + if tde4.getPGroupType(pg) == "CAMERA" + ), None + ) + selected_models = tde4.get3DModelList(point_group, 1)\ + if point_group else [] + try: + yield + finally: + if point_group: + # 3 restore model selection + for model in tde4.get3DModelList(point_group, 0): + if model in selected_models: + tde4.set3DModelSelectionFlag(point_group, model, 1) + else: + tde4.set3DModelSelectionFlag(point_group, model, 0) diff --git a/openpype/hosts/equalizer/api/plugin.py b/openpype/hosts/equalizer/api/plugin.py new file mode 100644 index 00000000000..deebc4f423a --- /dev/null +++ b/openpype/hosts/equalizer/api/plugin.py @@ -0,0 +1,159 @@ +"""Base plugin class for 3DEqualizer. + +note: + 3dequalizer 7.1v2 uses Python 3.7.9 + +""" +from abc import ABC +from typing import Dict, List + +from openpype.hosts.equalizer.api import EqualizerHost +from openpype.lib import BoolDef, EnumDef, NumberDef +from openpype.pipeline import ( + CreatedInstance, + Creator, + OptionalPyblishPluginMixin, +) + + +class EqualizerCreator(ABC, Creator): + + @property + def host(self) -> EqualizerHost: + """Return the host application.""" + # We need to cast the host to EqualizerHost, because the Creator + # class is not aware of the host application. + return super().host + + def create(self, subset_name, instance_data, pre_create_data): + """Create a subset in the host application. + + Args: + subset_name (str): Name of the subset to create. + instance_data (dict): Data of the instance to create. + pre_create_data (dict): Data from the pre-create step. + + Returns: + openpype.pipeline.CreatedInstance: Created instance. + """ + self.log.debug("EqualizerCreator.create") + instance = CreatedInstance( + self.family, + subset_name, + instance_data, + self) + self._add_instance_to_context(instance) + return instance + + def collect_instances(self): + """Collect instances from the host application. + + Returns: + list[openpype.pipeline.CreatedInstance]: List of instances. + """ + for instance_data in self.host.get_context_data().get( + "publish_instances", []): + created_instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(created_instance) + + def update_instances(self, update_list): + + # if not update_list: + # return + context = self.host.get_context_data() + if not context.get("publish_instances"): + context["publish_instances"] = [] + + instances_by_id = {} + for instance in context.get("publish_instances"): + # sourcery skip: use-named-expression + instance_id = instance.get("instance_id") + if instance_id: + instances_by_id[instance_id] = instance + + for instance, changes in update_list: + new_instance_data = changes.new_value + instance_data = instances_by_id.get(instance.id) + # instance doesn't exist, append everything + if instance_data is None: + context["publish_instances"].append(new_instance_data) + continue + + # update only changed values on instance + for key in set(instance_data) - set(new_instance_data): + instance_data.pop(key) + instance_data.update(new_instance_data) + + self.host.update_context_data(context, changes=update_list) + + def remove_instances(self, instances: List[Dict]): + context = self.host.get_context_data() + if not context.get("publish_instances"): + context["publish_instances"] = [] + + ids_to_remove = [ + instance.get("instance_id") + for instance in instances + ] + for instance in context.get("publish_instances"): + if instance.get("instance_id") in ids_to_remove: + context["publish_instances"].remove(instance) + + self.host.update_context_data(context, changes={}) + + +class ExtractScriptBase(OptionalPyblishPluginMixin): + """Base class for extract script plugins.""" + + hide_reference_frame = False + export_uv_textures = False + overscan_percent_width = 100 + overscan_percent_height = 100 + units = "mm" + + @classmethod + def apply_settings(cls, project_settings, system_settings): + settings = project_settings["equalizer"]["publish"][ + "ExtractMatchmoveScriptMaya"] # noqa + + cls.hide_reference_frame = settings.get( + "hide_reference_frame", cls.hide_reference_frame) + cls.export_uv_textures = settings.get( + "export_uv_textures", cls.export_uv_textures) + cls.overscan_percent_width = settings.get( + "overscan_percent_width", cls.overscan_percent_width) + cls.overscan_percent_height = settings.get( + "overscan_percent_height", cls.overscan_percent_height) + cls.units = settings.get("units", cls.units) + + @classmethod + def get_attribute_defs(cls): + defs = super(ExtractScriptBase, cls).get_attribute_defs() + + defs.extend([ + BoolDef("hide_reference_frame", + label="Hide Reference Frame", + default=cls.hide_reference_frame), + BoolDef("export_uv_textures", + label="Export UV Textures", + default=cls.export_uv_textures), + NumberDef("overscan_percent_width", + label="Overscan Width %", + default=cls.overscan_percent_width, + decimals=0, + minimum=1, + maximum=1000), + NumberDef("overscan_percent_height", + label="Overscan Height %", + default=cls.overscan_percent_height, + decimals=0, + minimum=1, + maximum=1000), + EnumDef("units", + ["mm", "cm", "m", "in", "ft", "yd"], + default=cls.units, + label="Units"), + ]) + return defs diff --git a/openpype/hosts/equalizer/hooks/pre_pyside2_install.py b/openpype/hosts/equalizer/hooks/pre_pyside2_install.py new file mode 100644 index 00000000000..81ef4d1a466 --- /dev/null +++ b/openpype/hosts/equalizer/hooks/pre_pyside2_install.py @@ -0,0 +1,196 @@ +"""Install PySide2 python module to 3dequalizer's python. + +If 3dequalizer doesn't have PySide2 module installed, it will try to install +it. + +Note: + This needs to be changed in the future so the UI is decoupled from the + host application. + +""" + +import contextlib +import os +import subprocess +from pathlib import Path +from platform import system + +from openpype.lib.applications import LaunchTypes, PreLaunchHook + + +class InstallPySide2(PreLaunchHook): + """Install Qt binding to 3dequalizer's python packages.""" + + app_groups = {"3dequalizer", "sdv_3dequalizer"} + launch_types = {LaunchTypes.local} + + def execute(self): + try: + self._execute() + except Exception: + self.log.warning(( + f"Processing of {self.__class__.__name__} " + "crashed."), exc_info=True + ) + + def _execute(self): + platform = system().lower() + executable = Path(self.launch_context.executable.executable_path) + expected_executable = "3de4" + if platform == "windows": + expected_executable += ".exe" + + if not self.launch_context.env.get("TDE4_HOME"): + if executable.name.lower() != expected_executable: + self.log.warning(( + f"Executable {executable.as_posix()} does not lead " + f"to {expected_executable} file. " + "Can't determine 3dequalizer's python to " + f"check/install PySide2. {executable.name}" + )) + return + python_dir = executable.parent.parent / "sys_data" / "py37_inst" + else: + python_dir = Path(self.launch_context.env["TDE4_HOME"]) / "sys_data" / "py37_inst" # noqa: E501 + + if platform == "windows": + python_executable = python_dir / "python.exe" + else: + python_executable = python_dir / "python" + # Check for python with enabled 'pymalloc' + if not python_executable.exists(): + python_executable = python_dir / "pythonm" + + if not python_executable.exists(): + self.log.warning( + "Couldn't find python executable " + f"for 3de4 {python_executable.as_posix()}" + ) + + return + + # Check if PySide2 is installed and skip if yes + if self.is_pyside_installed(python_executable): + self.log.debug("3Dequalizer has already installed PySide2.") + return + + # Install PySide2 in 3de4's python + if platform == "windows": + result = self.install_pyside_windows(python_executable) + else: + result = self.install_pyside(python_executable) + + if result: + self.log.info("Successfully installed PySide2 module to 3de4.") + else: + self.log.warning("Failed to install PySide2 module to 3de4.") + + def install_pyside_windows(self, python_executable: Path): + """Install PySide2 python module to 3de4'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. + + Note: + This is asking for administrative right always, no matter if + it is actually needed or not. Unfortunately getting + correct permissions for directory on Windows isn't that trivial. + You can either use `win32security` module or run `icacls` command + in subprocess and parse its output. + + """ + try: + import pywintypes + import win32con + import win32event + import win32process + from win32comext.shell import shellcon + from win32comext.shell.shell import ShellExecuteEx + except Exception: + self.log.warning("Couldn't import 'pywin32' modules") + return + + with contextlib.suppress(pywintypes.error): + # Parameters + # - use "-m pip" as module pip to install PySide2 and argument + # "--ignore-installed" is to force install module to 3de4'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.as_posix(), + lpParameters=parameters, + lpDirectory=python_executable.parent.as_posix() + ) + process_handle = process_info["hProcess"] + win32event.WaitForSingleObject( + process_handle, win32event.INFINITE) + return_code = win32process.GetExitCodeProcess(process_handle) + return return_code == 0 + + def install_pyside(self, python_executable: Path): + """Install PySide2 python module to 3de4's python.""" + + args = [ + python_executable.as_posix(), + "-m", + "pip", + "install", + "--ignore-installed", + "PySide2", + ] + + try: + # Parameters + # - use "-m pip" as module pip to install PySide2 and argument + # "--ignore-installed" is to force install module to 3de4 + # site-packages and make sure it is binary compatible + + process = subprocess.Popen( + args, stdout=subprocess.PIPE, universal_newlines=True + ) + process.communicate() + return process.returncode == 0 + except PermissionError: + self.log.warning( + f'Permission denied with command:\"{" ".join(args)}\".') + except OSError as error: + self.log.warning(f"OS error has occurred: \"{error}\".") + except subprocess.SubprocessError: + pass + + @staticmethod + def is_pyside_installed(python_executable: Path) -> bool: + """Check if PySide2 module is in 3de4 python env. + + Args: + python_executable (Path): Path to python executable. + + Returns: + bool: True if PySide2 is installed, False otherwise. + + """ + # Get pip list from 3de4's python executable + args = [python_executable.as_posix(), "-m", "pip", "list"] + process = subprocess.Popen(args, stdout=subprocess.PIPE) + stdout, _ = process.communicate() + lines = stdout.decode().split(os.linesep) + # Second line contain dashes that define maximum length of module name. + # Second column of dashes define maximum length of module version. + package_dashes, *_ = lines[1].split(" ") + package_len = len(package_dashes) + + # Got through printed lines starting at line 3 + for idx in range(2, len(lines)): + line = lines[idx] + if not line: + continue + package_name = line[:package_len].strip() + if package_name.lower() == "pyside2": + return True + return False diff --git a/openpype/hosts/equalizer/plugins/__init__.py b/openpype/hosts/equalizer/plugins/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openpype/hosts/equalizer/plugins/create/__init__.py b/openpype/hosts/equalizer/plugins/create/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openpype/hosts/equalizer/plugins/create/create_lens_distortion_data.py b/openpype/hosts/equalizer/plugins/create/create_lens_distortion_data.py new file mode 100644 index 00000000000..84e999ebff1 --- /dev/null +++ b/openpype/hosts/equalizer/plugins/create/create_lens_distortion_data.py @@ -0,0 +1,11 @@ +from openpype.hosts.equalizer.api import EqualizerCreator + + +class CreateLensDistortionData(EqualizerCreator): + identifier = "io.openpype.creators.equalizer.lens_distortion" + label = "Lens Distortion" + family = "lensDistortion" + icon = "glasses" + + def create(self, subset_name, instance_data, pre_create_data): + super().create(subset_name, instance_data, pre_create_data) diff --git a/openpype/hosts/equalizer/plugins/create/create_matchmove.py b/openpype/hosts/equalizer/plugins/create/create_matchmove.py new file mode 100644 index 00000000000..e0d5497cc71 --- /dev/null +++ b/openpype/hosts/equalizer/plugins/create/create_matchmove.py @@ -0,0 +1,55 @@ +import tde4 + +from openpype.hosts.equalizer.api import EqualizerCreator +from openpype.lib import EnumDef + + +class CreateMatchMove(EqualizerCreator): + identifier = "io.openpype.creators.equalizer.matchmove" + label = "Match Move" + family = "matchmove" + icon = "camera" + + def get_instance_attr_defs(self): + camera_enum = [ + {"value": "__all__", "label": "All Cameras"}, + {"value": "__current__", "label": "Current Camera"}, + {"value": "__ref__", "label": "Reference Cameras"}, + {"value": "__seq__", "label": "Sequence Cameras"}, + ] + camera_list = tde4.getCameraList() + camera_enum.extend( + {"label": tde4.getCameraName(camera), "value": camera} + for camera in camera_list + if tde4.getCameraEnabledFlag(camera) + ) + # try to get list of models + model_enum = [ + {"value": "__none__", "label": "No 3D Models At All"}, + {"value": "__all__", "label": "All 3D Models"}, + ] + point_groups = tde4.getPGroupList() + for point_group in point_groups: + model_list = tde4.get3DModelList(point_group, 0) + model_enum.extend( + { + "label": tde4.get3DModelName(point_group, model), + "value": model + } for model in model_list + ) + return [ + EnumDef("camera_selection", + items=camera_enum, + default="__current__", + label="Camera(s) to publish", + tooltip="Select cameras to publish"), + EnumDef("model_selection", + items=model_enum, + default="__none__", + label="Model(s) to publish", + tooltip="Select models to publish"), + ] + + def create(self, subset_name, instance_data, pre_create_data): + self.log.debug("CreateMatchMove.create") + super().create(subset_name, instance_data, pre_create_data) diff --git a/openpype/hosts/equalizer/plugins/load/__init__.py b/openpype/hosts/equalizer/plugins/load/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openpype/hosts/equalizer/plugins/load/load_plate.py b/openpype/hosts/equalizer/plugins/load/load_plate.py new file mode 100644 index 00000000000..f50b79e328f --- /dev/null +++ b/openpype/hosts/equalizer/plugins/load/load_plate.py @@ -0,0 +1,126 @@ +"""Loader for image sequences. + +This loads published sequence to the current camera +because this workflow is the most common in production. + +If current camera is not defined, it will try to use first camera and +if there is no camera at all, it will create new one. + +TODO: + * Support for setting handles, calculation frame ranges, EXR + options, etc. + * Add support for color management - at least put correct gamma + to image corrections. + +""" +import tde4 + +import openpype.pipeline.load as load +from openpype.client import get_version_by_id +from openpype.hosts.equalizer.api import Container, EqualizerHost +from openpype.lib.transcoding import IMAGE_EXTENSIONS +from openpype.pipeline import ( + get_current_project_name, + get_representation_context, +) + + +class LoadPlate(load.LoaderPlugin): + families = [ + "imagesequence", + "review", + "render", + "plate", + "image", + "online", + ] + + representations = ["*"] + extensions = {ext.lstrip(".") for ext in IMAGE_EXTENSIONS} + + label = "Load sequence" + order = -10 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, options=None): + representation = context["representation"] + project_name = get_current_project_name() + version = get_version_by_id(project_name, representation["parent"]) + + file_path = self.file_path(representation, context) + + camera = tde4.createCamera("SEQUENCE") + tde4.setCameraName(camera, name) + camera_name = tde4.getCameraName(camera) + + print( + f"Loading: {file_path} into {camera_name}") + + # set the path to sequence on the camera + tde4.setCameraPath(camera, file_path) + + # set the sequence attributes star/end/step + tde4.setCameraSequenceAttr( + camera, int(version["data"].get("frameStart")), + int(version["data"].get("frameEnd")), 1) + + container = Container( + name=name, + namespace=camera_name, + loader=self.__class__.__name__, + representation=str(representation["_id"]), + ) + print(container) + EqualizerHost.get_host().add_container(container) + + def update(self, container, representation): + camera_list = tde4.getCameraList() + try: + camera = [ + c for c in camera_list if + tde4.getCameraName(c) == container["namespace"] + ][0] + except IndexError: + self.log.error(f'Cannot find camera {container["namespace"]}') + print(f'Cannot find camera {container["namespace"]}') + return + + context = get_representation_context(representation) + file_path = self.file_path(representation, context) + + # set the path to sequence on the camera + tde4.setCameraPath(camera, file_path) + + version = get_version_by_id( + get_current_project_name(), representation["parent"]) + + # set the sequence attributes star/end/step + tde4.setCameraSequenceAttr( + camera, int(version["data"].get("frameStart")), + int(version["data"].get("frameEnd")), 1) + + print(container) + EqualizerHost.get_host().add_container(container) + + def switch(self, container, representation): + self.update(container, representation) + + def file_path(self, representation, context): + is_sequence = len(representation["files"]) > 1 + print(f"is sequence {is_sequence}") + if is_sequence: + frame = representation["context"]["frame"] + hashes = "#" * len(str(frame)) + if ( + "{originalBasename}" in representation["data"]["template"] + ): + origin_basename = context["originalBasename"] + context["originalBasename"] = origin_basename.replace( + frame, hashes + ) + + # Replace the frame with the hash in the frame + representation["context"]["frame"] = hashes + + return self.filepath_from_context(context) diff --git a/openpype/hosts/equalizer/plugins/publish/__init__.py b/openpype/hosts/equalizer/plugins/publish/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openpype/hosts/equalizer/plugins/publish/collect_3de_installation_dir.py b/openpype/hosts/equalizer/plugins/publish/collect_3de_installation_dir.py new file mode 100644 index 00000000000..93486824108 --- /dev/null +++ b/openpype/hosts/equalizer/plugins/publish/collect_3de_installation_dir.py @@ -0,0 +1,16 @@ +"""Collect camera data from the scene.""" +import pyblish.api +import tde4 +from pathlib import Path + + +class Collect3DE4InstallationDir(pyblish.api.InstancePlugin): + """Collect camera data from the scene.""" + + order = pyblish.api.CollectorOrder + hosts = ["equalizer"] + label = "Collect 3Dequalizer directory" + + def process(self, instance): + tde4_path = Path(tde4.get3DEInstallPath()) + instance.data["tde4_path"] = tde4_path diff --git a/openpype/hosts/equalizer/plugins/publish/collect_camera_data.py b/openpype/hosts/equalizer/plugins/publish/collect_camera_data.py new file mode 100644 index 00000000000..66e6c1320ae --- /dev/null +++ b/openpype/hosts/equalizer/plugins/publish/collect_camera_data.py @@ -0,0 +1,71 @@ +"""Collect camera data from the scene.""" +import pyblish.api +import tde4 + + +class CollectCameraData(pyblish.api.InstancePlugin): + """Collect camera data from the scene.""" + + order = pyblish.api.CollectorOrder + families = ["matchmove"] + hosts = ["equalizer"] + label = "Collect camera data" + + def process(self, instance: pyblish.api.Instance): + # handle camera selection. + # possible values are: + # - __current__ - current camera + # - __ref__ - reference cameras + # - __seq__ - sequence cameras + # - __all__ - all cameras + # - camera_id - specific camera + + try: + camera_sel = instance.data["creator_attributes"]["camera_selection"] # noqa: E501 + except KeyError: + self.log.warning("No camera defined") + return + + if camera_sel == "__all__": + cameras = tde4.getCameraList() + elif camera_sel == "__current__": + cameras = [tde4.getCurrentCamera()] + elif camera_sel in ["__ref__", "__seq__"]: + cameras = [ + c for c in tde4.getCameraList() + if tde4.getCameraType(c) == "REF_FRAME" + ] + else: + if camera_sel not in tde4.getCameraList(): + self.log.warning("Invalid camera found") + return + cameras = [camera_sel] + + data = [] + + for camera in cameras: + camera_name = tde4.getCameraName(camera) + enabled = tde4.getCameraEnabledFlag(camera) + # calculation range + c_range_start, c_range_end = tde4.getCameraCalculationRange( + camera) + p_range_start, p_range_end = tde4.getCameraPlaybackRange(camera) + fov = tde4.getCameraFOV(camera) + fps = tde4.getCameraFPS(camera) + # focal length is time based, so lets skip it for now + # focal_length = tde4.getCameraFocalLength(camera, frame) + path = tde4.getCameraPath(camera) + + camera_data = { + "name": camera_name, + "id": camera, + "enabled": enabled, + "calculation_range": (c_range_start, c_range_end), + "playback_range": (p_range_start, p_range_end), + "fov": fov, + "fps": fps, + # "focal_length": focal_length, + "path": path + } + data.append(camera_data) + instance.data["cameras"] = data diff --git a/openpype/hosts/equalizer/plugins/publish/collect_workfile.py b/openpype/hosts/equalizer/plugins/publish/collect_workfile.py new file mode 100644 index 00000000000..0ff45483c0b --- /dev/null +++ b/openpype/hosts/equalizer/plugins/publish/collect_workfile.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""Collect current work file.""" +from pathlib import Path + +import pyblish.api +import tde4 + + +class CollectWorkfile(pyblish.api.ContextPlugin): + """Inject the current working file into context""" + + order = pyblish.api.CollectorOrder - 0.01 + label = "Collect 3DE4 Workfile" + hosts = ['equalizer'] + + def process(self, context: pyblish.api.Context): + """Inject the current working file.""" + project_file = Path(tde4.getProjectPath()) + current_file = project_file.as_posix() + + context.data['currentFile'] = current_file + + filename = project_file.stem + ext = project_file.suffix + + task = context.data["task"] + + data = {} + + # create instance + instance = context.create_instance(name=filename) + subset = f'workfile{task.capitalize()}' + + data = { + "subset": subset, + "asset": context.data["asset"], + "label": subset, + "publish": True, + "family": 'workfile', + "families": ['workfile'], + "setMembers": [current_file], + "frameStart": context.data['frameStart'], + "frameEnd": context.data['frameEnd'], + "handleStart": context.data['handleStart'], + "handleEnd": context.data['handleEnd'], + "representations": [ + { + "name": ext.lstrip("."), + "ext": ext.lstrip("."), + "files": project_file.name, + "stagingDir": project_file.parent.as_posix(), + } + ] + } + + instance.data.update(data) + + self.log.info(f'Collected instance: {project_file.name}') + self.log.info(f'Scene path: {current_file}') + self.log.info(f'staging Dir: {project_file.parent.as_posix()}') + self.log.info(f'subset: {subset}') diff --git a/openpype/hosts/equalizer/plugins/publish/extract_lens_distortion_nuke.py b/openpype/hosts/equalizer/plugins/publish/extract_lens_distortion_nuke.py new file mode 100644 index 00000000000..407fd66f904 --- /dev/null +++ b/openpype/hosts/equalizer/plugins/publish/extract_lens_distortion_nuke.py @@ -0,0 +1,55 @@ +from pathlib import Path + +import pyblish.api +import tde4 # noqa: F401 + +from openpype.lib import import_filepath +from openpype.pipeline import OptionalPyblishPluginMixin, publish + + +class ExtractLensDistortionNuke(publish.Extractor, + OptionalPyblishPluginMixin): + """Extract Nuke script for matchmove. + + Unfortunately built-in export script from 3DEqualizer is bound to its UI, + and it is not possible to call it directly from Python. Because of that, + we are executing the script in the same way as artist would do it, but + we are patching the UI to silence it and to avoid any user interaction. + + TODO: Utilize attributes defined in ExtractScriptBase + """ + + label = "Extract Lens Distortion Nuke node" + families = ["lensDistortion"] + hosts = ["equalizer"] + + order = pyblish.api.ExtractorOrder + + def process(self, instance: pyblish.api.Instance): + + if not self.is_active(instance.data): + return + + cam = tde4.getCurrentCamera() + offset = tde4.getCameraFrameOffset(cam) + staging_dir = self.staging_dir(instance) + file_path = Path(staging_dir) / "nuke_ld_export.nk" + + # import export script from 3DEqualizer + exporter_path = instance.data["tde4_path"] / "sys_data" / "py_scripts" / "export_nuke_LD_3DE4_Lens_Distortion_Node.py" # noqa: E501 + self.log.debug(f"Importing {exporter_path.as_posix()}") + exporter = import_filepath(exporter_path.as_posix()) + exporter.exportNukeDewarpNode(cam, offset, file_path.as_posix()) + + # create representation data + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': "lensDistortion", + 'ext': "nk", + 'files': file_path.name, + "stagingDir": staging_dir, + } + self.log.debug(f"output: {file_path.as_posix()}") + instance.data["representations"].append(representation) diff --git a/openpype/hosts/equalizer/plugins/publish/extract_matchmove_script_maya.py b/openpype/hosts/equalizer/plugins/publish/extract_matchmove_script_maya.py new file mode 100644 index 00000000000..907c935209a --- /dev/null +++ b/openpype/hosts/equalizer/plugins/publish/extract_matchmove_script_maya.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +"""Extract project for Maya""" + +from pathlib import Path + +import pyblish.api +import tde4 + +from openpype.hosts.equalizer.api import ( + ExtractScriptBase, + maintained_model_selection, +) +from openpype.lib import import_filepath +from openpype.pipeline import ( + KnownPublishError, + OptionalPyblishPluginMixin, + publish, +) + + +class ExtractMatchmoveScriptMaya(publish.Extractor, + ExtractScriptBase, + OptionalPyblishPluginMixin): + """Extract Maya MEL script for matchmove. + + This is using built-in export script from 3DEqualizer. + """ + + label = "Extract Maya Script" + families = ["matchmove"] + hosts = ["equalizer"] + + order = pyblish.api.ExtractorOrder + + def process(self, instance: pyblish.api.Instance): + """Extracts Maya script from 3DEqualizer. + + This method is using export script shipped with 3DEqualizer to + maintain as much compatibility as possible. Instead of invoking it + from the UI, it calls directly the function that is doing the export. + For that it needs to pass some data that are collected in 3dequalizer + from the UI, so we need to determine them from the instance itself and + from the state of the project. + + """ + if not self.is_active(instance.data): + return + attr_data = self.get_attr_values_from_data(instance.data) + + # import maya export script from 3DEqualizer + exporter_path = instance.data["tde4_path"] / "sys_data" / "py_scripts" / "export_maya.py" # noqa: E501 + self.log.debug(f"Importing {exporter_path.as_posix()}") + exporter = import_filepath(exporter_path.as_posix()) + + # get camera point group + point_group = None + point_groups = tde4.getPGroupList() + for pg in point_groups: + if tde4.getPGroupType(pg) == "CAMERA": + point_group = pg + break + else: + # this should never happen as it should be handled by validator + raise RuntimeError("No camera point group found.") + + offset = tde4.getCameraFrameOffset(tde4.getCurrentCamera()) + overscan_width = attr_data["overscan_percent_width"] / 100.0 + overscan_height = attr_data["overscan_percent_height"] / 100.0 + + staging_dir = self.staging_dir(instance) + + unit_scales = { + "mm": 10.0, # cm -> mm + "cm": 1.0, # cm -> cm + "m": 0.01, # cm -> m + "in": 0.393701, # cm -> in + "ft": 0.0328084, # cm -> ft + "yd": 0.0109361 # cm -> yd + } + scale_factor = unit_scales[attr_data["units"]] + model_selection_enum = instance.data["creator_attributes"]["model_selection"] # noqa: E501 + + with maintained_model_selection(): + # handle model selection + # We are passing it to existing function that is expecting + # this value to be an index of selection type. + # 1 - No models + # 2 - Selected models + # 3 - All models + if model_selection_enum == "__none__": + model_selection = 1 + elif model_selection_enum == "__all__": + model_selection = 3 + else: + # take model from instance and set its selection flag on + # turn off all others + model_selection = 2 + point_groups = tde4.getPGroupList() + for point_group in point_groups: + model_list = tde4.get3DModelList(point_group, 0) + if model_selection_enum in model_list: + model_selection = 2 + tde4.set3DModelSelectionFlag( + point_group, instance.data["model_selection"], 1) + break + else: + # clear all other model selections + for model in model_list: + tde4.set3DModelSelectionFlag(point_group, model, 0) + + file_path = Path(staging_dir) / "maya_export.mel" + status = exporter._maya_export_mel_file( + file_path.as_posix(), # staging path + point_group, # camera point group + [c["id"] for c in instance.data["cameras"] if c["enabled"]], + model_selection, # model selection mode + overscan_width, + overscan_height, + 1 if attr_data["export_uv_textures"] else 0, + scale_factor, + offset, # start frame + 1 if attr_data["hide_reference_frame"] else 0) + + if status != 1: + raise KnownPublishError("Export failed.") + + # create representation data + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': "mel", + 'ext': "mel", + 'files': file_path.name, + "stagingDir": staging_dir, + } + self.log.debug(f"output: {file_path.as_posix()}") + instance.data["representations"].append(representation) diff --git a/openpype/hosts/equalizer/plugins/publish/extract_matchmove_script_nuke.py b/openpype/hosts/equalizer/plugins/publish/extract_matchmove_script_nuke.py new file mode 100644 index 00000000000..33638f310b0 --- /dev/null +++ b/openpype/hosts/equalizer/plugins/publish/extract_matchmove_script_nuke.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +"""Extract project for Nuke. + +Because original extractor script is intermingled with UI, we had to resort +to this hacky solution. This is monkey-patching 3DEqualizer UI to silence it +during the export. Advantage is that it is still using "vanilla" built-in +export script, so it should be more compatible with future versions of the +software. + +TODO: This can be refactored even better, split to multiple methods, etc. + +""" +from pathlib import Path +from unittest.mock import patch + +import pyblish.api +import tde4 # noqa: F401 + + +from openpype.pipeline import OptionalPyblishPluginMixin +from openpype.pipeline import publish + + +class ExtractMatchmoveScriptNuke(publish.Extractor, + OptionalPyblishPluginMixin): + """Extract Nuke script for matchmove. + + Unfortunately built-in export script from 3DEqualizer is bound to its UI, + and it is not possible to call it directly from Python. Because of that, + we are executing the script in the same way as artist would do it, but + we are patching the UI to silence it and to avoid any user interaction. + + TODO: Utilize attributes defined in ExtractScriptBase + """ + + label = "Extract Nuke Script" + families = ["matchmove"] + hosts = ["equalizer"] + + order = pyblish.api.ExtractorOrder + + def process(self, instance: pyblish.api.Instance): + + if not self.is_active(instance.data): + return + + cam = tde4.getCurrentCamera() + frame0 = tde4.getCameraFrameOffset(cam) + frame0 -= 1 + + staging_dir = self.staging_dir(instance) + file_path = Path(staging_dir) / "nuke_export.nk" + + # these patched methods are used to silence 3DEqualizer UI: + def patched_getWidgetValue(requester, key: str): # noqa: N802 + """Return value for given key in widget.""" + if key == "file_browser": + return file_path.as_posix() + elif key == "startframe_field": + return tde4.getCameraFrameOffset(cam) + return "" + + # This is simulating artist clicking on "OK" button + # in the export dialog. + def patched_postCustomRequester(*args, **kwargs): # noqa: N802 + return 1 + + # This is silencing success/error message after the script + # is exported. + def patched_postQuestionRequester(*args, **kwargs): # noqa: N802 + return None + + # import maya export script from 3DEqualizer + exporter_path = instance.data["tde4_path"] / "sys_data" / "py_scripts" / "export_nuke.py" # noqa: E501 + self.log.debug("Patching 3dequalizer requester objects ...") + + with patch("tde4.getWidgetValue", patched_getWidgetValue), \ + patch("tde4.postCustomRequester", patched_postCustomRequester), \ + patch("tde4.postQuestionRequester", patched_postQuestionRequester): # noqa: E501 + with exporter_path.open() as f: + script = f.read() + self.log.debug(f"Importing {exporter_path.as_posix()}") + exec(script) + + # create representation data + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': "nk", + 'ext': "nk", + 'files': file_path.name, + "stagingDir": staging_dir, + } + self.log.debug(f"output: {file_path.as_posix()}") + instance.data["representations"].append(representation) diff --git a/openpype/hosts/equalizer/plugins/publish/validate_camera_pointgroup.py b/openpype/hosts/equalizer/plugins/publish/validate_camera_pointgroup.py new file mode 100644 index 00000000000..8e013c6f28b --- /dev/null +++ b/openpype/hosts/equalizer/plugins/publish/validate_camera_pointgroup.py @@ -0,0 +1,28 @@ +import pyblish.api +import tde4 + +from openpype.pipeline.publish import ( + PublishValidationError, + ValidateContentsOrder, +) + + +class ValidateCameraPoingroup(pyblish.api.InstancePlugin): + """Validate Camera Point Group. + + There must be a camera point group in the scene. + """ + order = ValidateContentsOrder + hosts = ["equalizer"] + families = ["matchmove"] + label = "Validate Camera Point Group" + + def process(self, instance): + valid = False + for point_group in tde4.getPGroupList(): + if tde4.getPGroupType(point_group) == "CAMERA": + valid = True + break + + if not valid: + raise PublishValidationError("Missing Camera Point Group") diff --git a/openpype/hosts/equalizer/plugins/publish/validate_instance_camera_data.py b/openpype/hosts/equalizer/plugins/publish/validate_instance_camera_data.py new file mode 100644 index 00000000000..097bc0157b3 --- /dev/null +++ b/openpype/hosts/equalizer/plugins/publish/validate_instance_camera_data.py @@ -0,0 +1,23 @@ +import pyblish.api + +from openpype.pipeline import PublishValidationError +from openpype.pipeline.publish import ValidateContentsOrder + + +class ValidateInstanceCameraData(pyblish.api.InstancePlugin): + """Check if instance has camera data. + + There might not be any camera associated with the instance + and without it, the instance is not valid. + """ + + order = ValidateContentsOrder + hosts = ["equalizer"] + families = ["matchmove"] + label = "Validate Instance has Camera data" + + def process(self, instance): + try: + _ = instance.data["cameras"] + except KeyError as e: + raise PublishValidationError("No camera data found") from e diff --git a/openpype/hosts/equalizer/startup/ayon_create.py b/openpype/hosts/equalizer/startup/ayon_create.py new file mode 100644 index 00000000000..7969a423db4 --- /dev/null +++ b/openpype/hosts/equalizer/startup/ayon_create.py @@ -0,0 +1,23 @@ +# +# 3DE4.script.name: Create ... +# 3DE4.script.gui: Main Window::Ayon +# 3DE4.script.comment: Open AYON Publisher tool +# + +from openpype.pipeline import install_host, is_installed +from openpype.hosts.equalizer.api import EqualizerHost +from openpype.tools.utils import host_tools + + +def install_3de_host(): + print("Running AYON integration ...") + install_host(EqualizerHost()) + + +if not is_installed(): + install_3de_host() + +# show the UI +print("Opening publisher window ...") +host_tools.show_publisher( + tab="create", parent=EqualizerHost.get_host().get_main_window()) diff --git a/openpype/hosts/equalizer/startup/ayon_load.py b/openpype/hosts/equalizer/startup/ayon_load.py new file mode 100644 index 00000000000..2c21a51957f --- /dev/null +++ b/openpype/hosts/equalizer/startup/ayon_load.py @@ -0,0 +1,24 @@ +# +# 3DE4.script.name: Load ... +# 3DE4.script.gui: Main Window::Ayon +# 3DE4.script.comment: Open AYON Loader tool +# + +from openpype.pipeline import install_host, is_installed +from openpype.hosts.equalizer.api import EqualizerHost +from openpype.tools.utils import host_tools + + +def install_3de_host(): + print("Running AYON integration ...") + install_host(EqualizerHost()) + + +if not is_installed(): + install_3de_host() + +# show the UI +print("Opening loader window ...") +host_tools.show_loader( + parent=EqualizerHost.get_host().get_main_window(), + use_context=True) diff --git a/openpype/hosts/equalizer/startup/ayon_manage.py b/openpype/hosts/equalizer/startup/ayon_manage.py new file mode 100644 index 00000000000..3d44968afe4 --- /dev/null +++ b/openpype/hosts/equalizer/startup/ayon_manage.py @@ -0,0 +1,23 @@ +# +# 3DE4.script.name: Manage ... +# 3DE4.script.gui: Main Window::Ayon +# 3DE4.script.comment: Open AYON Publisher tool +# + +from openpype.pipeline import install_host, is_installed +from openpype.hosts.equalizer.api import EqualizerHost +from openpype.tools.utils import host_tools + + +def install_3de_host(): + print("Running AYON integration ...") + install_host(EqualizerHost()) + + +if not is_installed(): + install_3de_host() + +# show the UI +print("Opening Scene Manager window ...") +host_tools.show_scene_inventory( + parent=EqualizerHost.get_host().get_main_window()) diff --git a/openpype/hosts/equalizer/startup/ayon_publish.py b/openpype/hosts/equalizer/startup/ayon_publish.py new file mode 100644 index 00000000000..5e8b46148f7 --- /dev/null +++ b/openpype/hosts/equalizer/startup/ayon_publish.py @@ -0,0 +1,23 @@ +# +# 3DE4.script.name: Publish ... +# 3DE4.script.gui: Main Window::Ayon +# 3DE4.script.comment: Open AYON Publisher tool +# + +from openpype.pipeline import install_host, is_installed +from openpype.hosts.equalizer.api import EqualizerHost +from openpype.tools.utils import host_tools + + +def install_3de_host(): + print("Running AYON integration ...") + install_host(EqualizerHost()) + + +if not is_installed(): + install_3de_host() + +# show the UI +print("Opening publisher window ...") +host_tools.show_publisher( + tab="publish", parent=EqualizerHost.get_host().get_main_window()) diff --git a/openpype/hosts/equalizer/startup/ayon_workfile.py b/openpype/hosts/equalizer/startup/ayon_workfile.py new file mode 100644 index 00000000000..86e65a80f16 --- /dev/null +++ b/openpype/hosts/equalizer/startup/ayon_workfile.py @@ -0,0 +1,23 @@ +# +# 3DE4.script.name: Work files ... +# 3DE4.script.gui: Main Window::Ayon +# 3DE4.script.comment: Open AYON Publisher tool +# + +from openpype.pipeline import install_host, is_installed +from openpype.hosts.equalizer.api import EqualizerHost +from openpype.tools.utils import host_tools + + +def install_3de_host(): + print("Running AYON integration ...") + install_host(EqualizerHost()) + + +if not is_installed(): + install_3de_host() + +# show the UI +print("Opening Workfile tool window ...") +host_tools.show_workfiles( + parent=EqualizerHost.get_host().get_main_window()) diff --git a/openpype/hosts/equalizer/tests/test_plugin.py b/openpype/hosts/equalizer/tests/test_plugin.py new file mode 100644 index 00000000000..df766878590 --- /dev/null +++ b/openpype/hosts/equalizer/tests/test_plugin.py @@ -0,0 +1,137 @@ +""" +3DEqualizer plugin tests + +These test need to be run in 3DEqualizer. + +""" +import json +import re +import unittest + +from attrs import asdict, define, field +from attrs.exceptions import NotAnAttrsClassError + +AVALON_CONTAINER_ID = "test.container" + +CONTEXT_REGEX = re.compile( + r"AYON_CONTEXT::(?P.*?)::AYON_CONTEXT_END", + re.DOTALL) + + +@define +class Container(object): + + name: str = field(default=None) + id: str = field(init=False, default=AVALON_CONTAINER_ID) + namespace: str = field(default="") + loader: str = field(default=None) + representation: str = field(default=None) + + +class Tde4Mock: + """Simple class to mock few 3dequalizer functions. + + Just to run the test outside the host itself. + """ + + _notes = "" + + def isProjectUpToDate(self): + return True + + def setProjectNotes(self, notes): + self._notes = notes + + def getProjectNotes(self): + return self._notes + + +tde4 = Tde4Mock() + + +def get_context_data(): + m = re.search(CONTEXT_REGEX, tde4.getProjectNotes()) + return json.loads(m["context"]) if m else {} + + +def update_context_data(data, _): + m = re.search(CONTEXT_REGEX, tde4.getProjectNotes()) + if not m: + tde4.setProjectNotes("AYON_CONTEXT::::AYON_CONTEXT_END") + update = json.dumps(data, indent=4) + tde4.setProjectNotes( + re.sub( + CONTEXT_REGEX, + f"AYON_CONTEXT::{update}::AYON_CONTEXT_END", + tde4.getProjectNotes() + ) + ) + + +def get_containers(): + return get_context_data().get("containers", []) + + +def add_container(container: Container): + context_data = get_context_data() + containers = get_context_data().get("containers", []) + + for _container in containers: + if _container["name"] == container.name and _container["namespace"] == container.namespace: # noqa: E501 + containers.remove(_container) + break + + try: + containers.append(asdict(container)) + except NotAnAttrsClassError: + print("not an attrs class") + containers.append(container) + + context_data["containers"] = containers + update_context_data(context_data, changes={}) + + +class TestEqualizer(unittest.TestCase): + def test_context_data(self): + # ensure empty project notest + + data = get_context_data() + self.assertEqual({}, data, "context data are not empty") + + # add container + add_container( + Container(name="test", representation="test_A") + ) + + self.assertEqual( + 1, len(get_containers()), "container not added") + self.assertEqual( + get_containers()[0]["name"], + "test", "container name is not correct") + + # add another container + add_container( + Container(name="test2", representation="test_B") + ) + + self.assertEqual( + 2, len(get_containers()), "container not added") + self.assertEqual( + get_containers()[1]["name"], + "test2", "container name is not correct") + + # update container + add_container( + Container(name="test2", representation="test_C") + ) + self.assertEqual( + 2, len(get_containers()), "container not updated") + self.assertEqual( + get_containers()[1]["representation"], + "test_C", "container name is not correct") + + print(f"--4: {tde4.getProjectNotes()}") + + +if __name__ == "__main__": + unittest.main() diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index e684a91fe23..38df607665b 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -613,7 +613,13 @@ def get_custom_namespace_and_group(self, context, options, loader_key): """ options["attach_to_root"] = True - custom_naming = self.load_settings[loader_key] + try: + custom_naming = self.load_settings[loader_key] + except KeyError: + self.log.warning( + "No settings found for {} in settings, falling back to " + "ReferenceLoader defaults.".format(loader_key)) + custom_naming = self.load_settings["reference_loader"] if not custom_naming['namespace']: raise LoadError("No namespace specified in " diff --git a/openpype/hosts/maya/plugins/load/load_matchmove.py b/openpype/hosts/maya/plugins/load/load_matchmove.py index 46d1be8300e..132fff241dd 100644 --- a/openpype/hosts/maya/plugins/load/load_matchmove.py +++ b/openpype/hosts/maya/plugins/load/load_matchmove.py @@ -1,11 +1,24 @@ -from maya import mel -from openpype.pipeline import load +# -*- coding: utf-8 -*- +from maya import cmds, mel # noqa: F401 -class MatchmoveLoader(load.LoaderPlugin): - """ - This will run matchmove script to create track in scene. +from openpype.hosts.maya.api.pipeline import containerise +from openpype.hosts.maya.api import lib, Loader +from openpype.pipeline.load import get_representation_path, LoadError + + +class MatchmoveLoader(Loader): + """Run matchmove script to create track in scene. Supported script types are .py and .mel + + TODO: there might be error in the scripts exported from + 3DEqualizer that it is trying to set frame attribute + on camera image plane and then add expression for + image sequence. Maya will throw RuntimeError at that + point that will stop processing rest of the script and + the container will not be created. We should somehow handle + this - maybe even by patching the mel script on-the-fly. + """ families = ["matchmove"] @@ -16,15 +29,80 @@ class MatchmoveLoader(load.LoaderPlugin): icon = "empire" color = "orange" - def load(self, context, name, namespace, data): + def load(self, context, name, namespace, options): + path = self.filepath_from_context(context) + custom_group_name, custom_namespace, options = \ + self.get_custom_namespace_and_group( + context, options, "matchmove_loader") + + namespace = lib.get_custom_namespace(custom_namespace) + + nodes = self._load_nodes_from_script(path) + + return containerise( + name=name, + namespace=namespace, + nodes=nodes, + context=context, + loader=self.__class__.__name__ + ) + + def update(self, container, representation): + # type: (dict, dict) -> None + """Update container with specified representation.""" + self.remove(container) + + path = get_representation_path(representation) + namespace = container["namespace"] + print(f">>> loading from {path}") + try: + nodes = self._load_nodes_from_script(path) + except RuntimeError as e: + raise LoadError("Failed to load matchmove script.") from e + + return containerise( + name=container["name"], + namespace=namespace, + nodes=nodes, + context=representation["context"], + loader=self.__class__.__name__ + ) + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + """Delete container and its contents.""" + + if cmds.objExists(container['objectName']): + members = cmds.sets(container['objectName'], query=True) or [] + cmds.delete([container['objectName']] + members) + + def _load_nodes_from_script(self, path): + # type: (str) -> list + """Load nodes from script. + + This will execute py or mel script and resulting + nodes will be returned. + + Args: + path (str): path to script + + Returns: + list: list of created nodes + + """ + previous_nodes = set(cmds.ls(long=True)) + if path.lower().endswith(".py"): exec(open(path).read()) elif path.lower().endswith(".mel"): - mel.eval('source "{}"'.format(path)) + mel.eval(open(path).read()) else: self.log.error("Unsupported script type") - return True + current_nodes = set(cmds.ls(long=True)) + return list(current_nodes - previous_nodes) diff --git a/openpype/hosts/nuke/plugins/load/load_backdrop.py b/openpype/hosts/nuke/plugins/load/load_backdrop.py index 54d37da2036..ae562765d83 100644 --- a/openpype/hosts/nuke/plugins/load/load_backdrop.py +++ b/openpype/hosts/nuke/plugins/load/load_backdrop.py @@ -25,7 +25,7 @@ class LoadBackdropNodes(load.LoaderPlugin): """Loading Published Backdrop nodes (workfile, nukenodes)""" - families = ["workfile", "nukenodes"] + families = ["workfile", "nukenodes", "matchmove"] representations = ["*"] extensions = {"nk"} diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py index 19b5cca74ef..4a9909a31be 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo.py @@ -25,7 +25,7 @@ class LoadGizmo(load.LoaderPlugin): """Loading nuke Gizmo""" - families = ["gizmo"] + families = ["gizmo", "lensDistortion"] representations = ["*"] extensions = {"nk"} diff --git a/openpype/lib/log.py b/openpype/lib/log.py index 72071063ec6..3458f097417 100644 --- a/openpype/lib/log.py +++ b/openpype/lib/log.py @@ -102,6 +102,12 @@ def emit(self, record): except OSError: self.handleError(record) + except ValueError: + # this is raised when logging during interpreter shutdown + # or it real edge cases where logging stream is already closed. + # In particular, it happens a lot in 3DEqualizer. + # TODO: remove this condition when the cause is found. + pass except Exception: print(repr(record)) diff --git a/openpype/plugins/publish/collect_scene_loaded_versions.py b/openpype/plugins/publish/collect_scene_loaded_versions.py index 627d451f582..f329932c9a7 100644 --- a/openpype/plugins/publish/collect_scene_loaded_versions.py +++ b/openpype/plugins/publish/collect_scene_loaded_versions.py @@ -20,7 +20,8 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): "nuke", "photoshop", "resolve", - "tvpaint" + "tvpaint", + "equalizer", ] def process(self, context): diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index 581c0c012fa..61efc34a5f4 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -141,7 +141,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "uasset", "blendScene", "yeticacheUE", - "tycache" + "tycache", + "lensDistortion", ] default_template_name = "publish" diff --git a/openpype/resources/app_icons/3de4.png b/openpype/resources/app_icons/3de4.png new file mode 100644 index 00000000000..bd0fe40d37a Binary files /dev/null and b/openpype/resources/app_icons/3de4.png differ diff --git a/openpype/settings/defaults/project_settings/equalizer.json b/openpype/settings/defaults/project_settings/equalizer.json new file mode 100644 index 00000000000..3f8feafa002 --- /dev/null +++ b/openpype/settings/defaults/project_settings/equalizer.json @@ -0,0 +1,15 @@ +{ + "create": { + "CreateMatchMove": { + "enabled": true, + "default_variants": [ + "CameraTrack", + "ObjectTrack", + "PointTrack", + "Stabilize", + "SurveyTrack", + "UserTrack" + ] + } + } +} diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index a5283751e97..180486e5ed4 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -1584,8 +1584,8 @@ "environment": {} }, "__dynamic_keys_labels__": { - "5-1": "Unreal 5.1", - "5-0": "Unreal 5.0" + "5-0": "Unreal 5.0", + "5-1": "Unreal 5.1" } } }, @@ -1615,5 +1615,34 @@ } } }, + "3dequalizer": { + "enabled": true, + "label": "3DEqualizer", + "icon": "{}/app_icons/3de4.png", + "host_name": "equalizer", + "heartbeat_interval": 500, + "environment": {}, + "variants": { + "7-1v2": { + "use_python_2": false, + "executables": { + "windows": [ + "C:\\Program Files\\3DE4_win64_r7.1v2\\bin\\3DE4.exe" + ], + "darwin": [], + "linux": [] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": {} + }, + "__dynamic_keys_labels__": { + "7-1v2": "7.1v2" + } + } + }, "additional_apps": {} } diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 26ecd33551a..dcbcb9cb35a 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -172,7 +172,8 @@ class HostsEnumEntity(BaseEnumEntity): "standalonepublisher", "substancepainter", "traypublisher", - "webpublisher" + "webpublisher", + "equalizer", ] def _item_initialization(self): diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index 4315987a33e..c0d8d393645 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -162,6 +162,10 @@ "type": "schema", "name": "schema_project_unreal" }, + { + "type": "schema", + "name": "schema_project_equalizer" + }, { "type": "dynamic_schema", "name": "project_settings/global" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_equalizer.json b/openpype/settings/entities/schemas/projects_schema/schema_project_equalizer.json new file mode 100644 index 00000000000..81fbddcb327 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_equalizer.json @@ -0,0 +1,13 @@ +{ + "type": "dict", + "collapsible": true, + "key": "equalizer", + "label": "3DEqualizer", + "is_file": true, + "children": [ + { + "type": "schema", + "name": "schema_equalizer_create" + } + ] +} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_equalizer_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_equalizer_create.json new file mode 100644 index 00000000000..c9b3f2fe7d3 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_equalizer_create.json @@ -0,0 +1,28 @@ +{ + "type": "dict", + "collapsible": true, + "key": "create", + "label": "Creator plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CreateMatchMove", + "label": "Create Match Move", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default Variants", + "object_type": "text" + } + ] + } + ] +} diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_3dequalizer.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_3dequalizer.json new file mode 100644 index 00000000000..618c57bd7b3 --- /dev/null +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_3dequalizer.json @@ -0,0 +1,43 @@ +{ + "type": "dict", + "key": "3dequalizer", + "label": "3DEqualizer", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "schema_template", + "name": "template_host_unchangables" + }, { + "type": "number", + "key": "heartbeat_interval", + "label": "Qt Heartbeat Interval (ms)" + }, + { + "key": "environment", + "label": "Environment", + "type": "raw-json" + }, + { + "type": "dict-modifiable", + "key": "variants", + "collapsible_key": true, + "use_label_wrap": false, + "object_type": { + "type": "dict", + "collapsible": true, + "children": [ + { + "type": "schema_template", + "name": "template_host_variant_items" + } + ] + } + } + ] +} diff --git a/openpype/settings/entities/schemas/system_schema/schema_applications.json b/openpype/settings/entities/schemas/system_schema/schema_applications.json index 7965c344aee..d12fe0a1535 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_applications.json +++ b/openpype/settings/entities/schemas/system_schema/schema_applications.json @@ -109,6 +109,10 @@ "type": "schema", "name": "schema_djv" }, + { + "type": "schema", + "name": "schema_3dequalizer" + }, { "type": "dict-modifiable", "key": "additional_apps", diff --git a/server_addon/applications/server/applications.json b/server_addon/applications/server/applications.json index 82dfd3b8d32..40853a562b8 100644 --- a/server_addon/applications/server/applications.json +++ b/server_addon/applications/server/applications.json @@ -1175,6 +1175,57 @@ } ] }, + "djvview": { + "enabled": true, + "label": "DJV View", + "icon": "{}/app_icons/djvView.png", + "host_name": "", + "environment": "{}", + "variants": [ + { + "name": "1-1", + "label": "1.1", + "executables": { + "windows": [], + "darwin": [], + "linux": [] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": "{}" + } + ] + }, + "equalizer": { + "enabled": true, + "label": "3DEqualizer", + "icon": "{}/app_icons/3de4.png", + "host_name": "equalizer", + "environment": "{}", + "variants": [ + { + "name": "7-1v2", + "label": "7.1v2", + "use_python_2": false, + "executables": { + "windows": [ + "C:\\Program Files\\3DE4_win64_r7.1v2\\bin\\3DE4.exe" + ], + "darwin": [], + "linux": [] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": "{}" + } + ] + }, "wrap": { "enabled": true, "label": "Wrap", diff --git a/server_addon/applications/server/settings.py b/server_addon/applications/server/settings.py index e0a59604c8c..c651a3cde14 100644 --- a/server_addon/applications/server/settings.py +++ b/server_addon/applications/server/settings.py @@ -186,6 +186,8 @@ class ApplicationsSettings(BaseSettingsModel): default_factory=AppGroupWithPython, title="Unreal Editor") wrap: AppGroup = SettingsField( default_factory=AppGroupWithPython, title="Wrap") + equalizer: AppGroup = SettingsField( + default_factory=AppGroupWithPython, title="3DEqualizer") additional_apps: list[AdditionalAppGroup] = SettingsField( default_factory=list, title="Additional Applications") diff --git a/server_addon/equalizer/LICENSE b/server_addon/equalizer/LICENSE new file mode 100644 index 00000000000..7ef6641787e --- /dev/null +++ b/server_addon/equalizer/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 YNPUT, s.r.o. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/server_addon/equalizer/README.md b/server_addon/equalizer/README.md new file mode 100644 index 00000000000..b01605c8ca2 --- /dev/null +++ b/server_addon/equalizer/README.md @@ -0,0 +1,4 @@ +3DEqualizer Integration for AYON +=============================== + +This is addon for AYON that allows to integrate 3DEqualizer with AYON. diff --git a/server_addon/equalizer/server/__init__.py b/server_addon/equalizer/server/__init__.py new file mode 100644 index 00000000000..4e44ec5748e --- /dev/null +++ b/server_addon/equalizer/server/__init__.py @@ -0,0 +1,16 @@ +from ayon_server.addons import BaseServerAddon + +from .settings import EqualizerSettings, DEFAULT_EQUALIZER_SETTING +from .version import __version__ + + +class Equalizer(BaseServerAddon): + name = "equalizer" + title = "3DEqualizer" + version = __version__ + + settings_model = EqualizerSettings + + async def get_default_settings(self): + settings_model_cls = self.get_settings_model() + return settings_model_cls(**DEFAULT_EQUALIZER_SETTING) diff --git a/server_addon/equalizer/server/settings/__init__.py b/server_addon/equalizer/server/settings/__init__.py new file mode 100644 index 00000000000..4fd64db59b9 --- /dev/null +++ b/server_addon/equalizer/server/settings/__init__.py @@ -0,0 +1,10 @@ +from .main import ( + EqualizerSettings, + DEFAULT_EQUALIZER_SETTING, +) + + +__all__ = ( + "EqualizerSettings", + "DEFAULT_EQUALIZER_SETTING", +) diff --git a/server_addon/equalizer/server/settings/creator_plugins.py b/server_addon/equalizer/server/settings/creator_plugins.py new file mode 100644 index 00000000000..cb475b07aa3 --- /dev/null +++ b/server_addon/equalizer/server/settings/creator_plugins.py @@ -0,0 +1,32 @@ +from ayon_server.settings import BaseSettingsModel +from pydantic import Field + + +class BasicCreatorModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + default_variants: list[str] = Field( + default_factory=list, + title="Default Variants" + ) + + +class EqualizerCreatorPlugins(BaseSettingsModel): + CreateMatchMove: BasicCreatorModel = Field( + default_factory=BasicCreatorModel, + title="Create Match Move data" + ) + + +EQ_CREATORS_PLUGINS_DEFAULTS = { + "CreateMatchMove": { + "enabled": True, + "default_variants": [ + "CameraTrack", + "ObjectTrack", + "PointTrack", + "Stabilize", + "SurveyTrack", + "UserTrack", + ] + }, +} diff --git a/server_addon/equalizer/server/settings/main.py b/server_addon/equalizer/server/settings/main.py new file mode 100644 index 00000000000..0e839abf5bf --- /dev/null +++ b/server_addon/equalizer/server/settings/main.py @@ -0,0 +1,30 @@ +from pydantic import Field +from ayon_server.settings import BaseSettingsModel + +from .creator_plugins import ( + EqualizerCreatorPlugins, + EQ_CREATORS_PLUGINS_DEFAULTS, +) +# from .publish_plugins import ( +# EqualizerPublishPlugins, +# EQ_PUBLISH_PLUGINS_DEFAULTS, +# ) + + +class EqualizerSettings(BaseSettingsModel): + """AfterEffects Project Settings.""" + + create: EqualizerCreatorPlugins = Field( + default_factory=EqualizerCreatorPlugins, + title="Creator plugins" + ) + # publish: EqualizerPublishPlugins = Field( + # default_factory=EqualizerPublishPlugins, + # title="Publish plugins" + # ) + + +DEFAULT_EQUALIZER_SETTING = { + "create": EQ_CREATORS_PLUGINS_DEFAULTS, + # "publish": EQ_PUBLISH_PLUGINS_DEFAULTS, +} diff --git a/server_addon/equalizer/server/version.py b/server_addon/equalizer/server/version.py new file mode 100644 index 00000000000..f735d8cf2ea --- /dev/null +++ b/server_addon/equalizer/server/version.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +"""Package declaring addon version.""" +__version__ = "0.0.1"