From e137d3b0e416633d1c23910c71d74e1de09e5905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 30 Jan 2025 17:28:04 +0100 Subject: [PATCH] :sparkles: add support for publishing shapes --- client/ayon_mocha/addon.py | 4 +- client/ayon_mocha/api/lib.py | 48 +++- client/ayon_mocha/api/pipeline.py | 26 +- .../plugins/create/create_shape_data.py | 51 ++++ .../plugins/publish/collect_instances.py | 48 ++++ .../plugins/publish/collect_mocha_paths.py | 59 ++++ .../plugins/publish/collect_mocha_project.py | 32 +++ .../plugins/publish/collect_shapes.py | 103 +++++++ .../plugins/publish/export_shapes.py | 262 ++++++++++++++++++ .../publish/validate_layers_and_exporters.py | 61 ++++ 10 files changed, 688 insertions(+), 6 deletions(-) create mode 100644 client/ayon_mocha/plugins/create/create_shape_data.py create mode 100644 client/ayon_mocha/plugins/publish/collect_instances.py create mode 100644 client/ayon_mocha/plugins/publish/collect_mocha_paths.py create mode 100644 client/ayon_mocha/plugins/publish/collect_mocha_project.py create mode 100644 client/ayon_mocha/plugins/publish/collect_shapes.py create mode 100644 client/ayon_mocha/plugins/publish/export_shapes.py create mode 100644 client/ayon_mocha/plugins/publish/validate_layers_and_exporters.py diff --git a/client/ayon_mocha/addon.py b/client/ayon_mocha/addon.py index a7e5c2d..9991ce8 100644 --- a/client/ayon_mocha/addon.py +++ b/client/ayon_mocha/addon.py @@ -4,14 +4,14 @@ import os from typing import Any -from ayon_core.addon import AYONAddon, IHostAddon, IPluginPaths +from ayon_core.addon import AYONAddon, IHostAddon from .version import __version__ MOCHA_ADDON_ROOT = os.path.dirname(os.path.abspath(__file__)) -class MochaAddon(AYONAddon, IHostAddon, IPluginPaths): +class MochaAddon(AYONAddon, IHostAddon): """BorisFX Mocha Pro addon for AYON.""" name = "mocha" diff --git a/client/ayon_mocha/api/lib.py b/client/ayon_mocha/api/lib.py index b2c691a..2c7cf3b 100644 --- a/client/ayon_mocha/api/lib.py +++ b/client/ayon_mocha/api/lib.py @@ -1,14 +1,18 @@ """Library functions for the Ayon Mocha API.""" from __future__ import annotations +import dataclasses +import re import subprocess import sys import tempfile +from hashlib import sha256 from pathlib import Path from shutil import copyfile -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Optional, Union from mocha import get_mocha_exec_name, ui +from mocha.exporters import ShapeDataExporter, TrackingDataExporter from mocha.project import Clip, Project from qtpy.QtWidgets import QApplication @@ -18,6 +22,35 @@ from qtpy import QtWidgets +EXTENSION_PATTERN = re.compile(r"(?P.+)\(\*\.(?P\w+)\)") + + +""" +These dataclasses are here because they +cannot be defined directly in pyblish plugins. +There seems to be an issue (at least in python 3.7) +with dataclass checking for __module__ in class and +that one is missing in discovered pyblish +plugin classes. +""" +@dataclasses.dataclass +class ExporterInfo: + """Exporter information.""" + id: str + label: str + exporter: Union[TrackingDataExporter, ShapeDataExporter] + + +@dataclasses.dataclass +class ExporterProcessInfo: + """Exporter process information.""" + mocha_python_path: Path + mocha_exporter_path: Path + current_project_path: Path + staging_dir: Path + options: dict[str, bool] + + def get_main_window() -> QtWidgets.QWidget: """Get the main window of the application. @@ -26,6 +59,7 @@ def get_main_window() -> QtWidgets.QWidget: """ return ui.get_widgets()["MainWindow"] + def update_ui() -> None: """Update the UI.""" QApplication.processEvents() @@ -131,3 +165,15 @@ def create_empty_project( clip_path = copy_placeholder_clip(project_path.parent) clip = Clip(clip_path.as_posix()) return Project(clip) + + +def get_shape_exporters() -> list[ExporterInfo]: + """Return all registered shape exporters as a list.""" + return [ + ExporterInfo( + id=sha256(k.encode()).hexdigest(), + label=k, + exporter=v) + for k, v in sorted( + ShapeDataExporter.registered_exporters().items()) + ] diff --git a/client/ayon_mocha/api/pipeline.py b/client/ayon_mocha/api/pipeline.py index b02bd78..ef1d1c1 100644 --- a/client/ayon_mocha/api/pipeline.py +++ b/client/ayon_mocha/api/pipeline.py @@ -32,14 +32,14 @@ from ayon_mocha.api.lib import update_ui -from .lib import create_empy_project, get_main_window +from .lib import create_empty_project, get_main_window from .workio import current_file, file_extensions, open_file, save_file if TYPE_CHECKING: from qtpy import QtWidgets log = logging.getLogger("ayon_mocha") -HOST_DIR = Path(__file__).resolve().parent +HOST_DIR = Path(__file__).resolve().parent.parent PLUGINS_DIR = HOST_DIR / "plugins" PUBLISH_PATH = PLUGINS_DIR / "publish" LOAD_PATH = PLUGINS_DIR / "load" @@ -57,6 +57,26 @@ MOCHA_INSTANCES_KEY = "publish_instances" MOCHA_CONTAINERS_KEY = "containers" +SHAPE_EXPORTERS_REPRESENTATION_NAME_MAPPING = { + "Adobe After Effects Mask Data (*.shape4ae)": "AfxMask", + "Adobe Premiere shape data (*.xml)": "PremiereShape", + "BlackMagic Fusion 19+ MultiPoly shapes (*.comp)": "FusionMultiPoly", + "BlackMagic Fusion shapes (*.comp)": "FusionShapes", + "Combustion GMask Script (*.gmask)": "CombustionGMask", + "Flame GMask Script (*.gmask)": "FlameGMask", + "Flame Tracer [Basic] (*.mask)": "FlameTracerBasic", + "Flame Tracer [Shape + Axis] (*.mask)": "FlameTracerShapeAxis", + "HitFilm [Transform & Shape] (*.hfcs)": "HitFilmTransformShape", + "Mocha shape data for Final Cut (*.xml)": "MochaShapeFinalCut", + "MochaBlend shape data (*.txt)": "MochaBlend", + "Nuke Roto [Basic] (*.nk)": "NuRotoBasic", + "Nuke RotoPaint [Basic] (*.nk)": "NukeRotoPaintBasic", + "Nuke SplineWarp (*.nk)": "NukeSplineWarp", + "Nuke v6.2+ Roto [Transform & Shape] (*.nk)": "NukeRotoTransformShape", + "Nuke v6.2+ RotoPaint [Transform & Shape] (*.nk)": "NukeRotoPaint", + "Shake Rotoshape (*.ssf)": "ShapeRotoshape", + "Silhouette shapes (*.fxs)": "SilhouetteShapes", +} class AYONJSONEncoder(json.JSONEncoder): """Custom JSON encoder for dataclasses.""" @@ -424,7 +444,7 @@ def get_current_project(self) -> Project: parent=get_main_window(), ) self._uninitialized_project_warning_shown = True - return create_empy_project() + return create_empty_project() return project diff --git a/client/ayon_mocha/plugins/create/create_shape_data.py b/client/ayon_mocha/plugins/create/create_shape_data.py new file mode 100644 index 0000000..a33b6f1 --- /dev/null +++ b/client/ayon_mocha/plugins/create/create_shape_data.py @@ -0,0 +1,51 @@ +"""Create shape data instance.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ayon_core.lib import ( + EnumDef, + UILabelDef, + UISeparatorDef, +) +from ayon_mocha.api.lib import get_shape_exporters +from ayon_mocha.api.plugin import MochaCreator + +if TYPE_CHECKING: + from ayon_core.pipeline import CreatedInstance + + +class CreateShapeData(MochaCreator): + """Create shape instance.""" + identifier = "io.ayon.creators.mochapro.matteshapes" + label = "Shape Data" + description = __doc__ + product_type = "matteshapes" + icon = "circle" + + def get_attr_defs_for_instance(self, instance: CreatedInstance) -> list: + """Get attribute definitions for instance.""" + exporter_items = {ex.id: ex.label for ex in get_shape_exporters()} + layers = { + idx: layer.name + for idx, layer in enumerate( + self.create_context.host.get_current_project().layers) + } or {-1: "No layers"} + + return [ + EnumDef("layers", + label="Layers", + items=layers, + multiselection=True), + EnumDef("exporter", + label="Exporter format", + items=exporter_items, multiselection=True), + UISeparatorDef(), + UILabelDef( + "Exporter Options (not all are available in all exporters)"), + EnumDef("layer_mode", label="Layer mode", + items={ + "selected": "Selected layers", + "all": "All layers" + }), + ] diff --git a/client/ayon_mocha/plugins/publish/collect_instances.py b/client/ayon_mocha/plugins/publish/collect_instances.py new file mode 100644 index 0000000..9e3d2c8 --- /dev/null +++ b/client/ayon_mocha/plugins/publish/collect_instances.py @@ -0,0 +1,48 @@ +"""Collect instances for publishing.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +import pyblish.api + +if TYPE_CHECKING: + from logging import Logger + +class CollectInstances(pyblish.api.InstancePlugin): + """Collect instances for publishing.""" + label = "Collect Instances" + order = pyblish.api.CollectorOrder - 0.4 + hosts: ClassVar[list[str]] = ["mochapro"] + log: Logger + + def process(self, instance: pyblish.api.Instance) -> None: + """Process the plugin.""" + self.log.debug("Collecting data for %s", instance) + + # Define nice instance label + instance_node = instance.data.get( + "transientData", {}).get("instance_node") + name = instance_node.label if instance_node else instance.name + label = f"{name} ({instance.data['folderPath']})" + + # Set frame start handle and frame end handle if frame ranges are + # available + if "frameStart" in instance.data and "frameEnd" in instance.data: + # Enforce existence if handles + instance.data.setdefault("handleStart", 0) + instance.data.setdefault("handleEnd", 0) + + # Compute frame start handle and end start handle + frame_start_handle = ( + instance.data["frameStart"] - instance.data["handleStart"] + ) + frame_end_handle = ( + instance.data["frameEnd"] - instance.data["handleEnd"] + ) + instance.data["frameStartHandle"] = frame_start_handle + instance.data["frameEndHandle"] = frame_end_handle + + # Include frame range in label + label += f" [{int(frame_start_handle)}-{int(frame_end_handle)}]" + + instance.data["label"] = label diff --git a/client/ayon_mocha/plugins/publish/collect_mocha_paths.py b/client/ayon_mocha/plugins/publish/collect_mocha_paths.py new file mode 100644 index 0000000..0002a52 --- /dev/null +++ b/client/ayon_mocha/plugins/publish/collect_mocha_paths.py @@ -0,0 +1,59 @@ +"""Collect Mocha executable paths.""" +from __future__ import annotations + +import platform +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar + +import pyblish.api +from mocha import get_mocha_exec_name + +if TYPE_CHECKING: + from logging import Logger + + from mocha.project import Project + +class CollectMochaPaths(pyblish.api.ContextPlugin): + """Collect Mocha Pro project.""" + order = pyblish.api.CollectorOrder - 0.45 + label = "Collect Mocha Pro executables" + hosts: ClassVar[list[str]] = ["mochapro"] + log: Logger + + def process(self, context: pyblish.api.Context) -> None: + """Process the plugin.""" + project: Project = context.data["project"] + self.log.info("Collected Mocha Pro project: %s", project) + + mocha_executable_path = Path(get_mocha_exec_name("mochapro")) + context.data["mocha_executable_path"] = mocha_executable_path + mocha_install_dir = mocha_executable_path.parent.parent + + if platform.system().lower() == "windows": + mocha_python_path = ( + mocha_install_dir / "python" / "python.exe") + mocha_exporter_path = ( + mocha_install_dir / "python" / "mochaexport.py") + elif platform.system().lower() == "darwin": + mocha_python_path = ( + mocha_install_dir / "python3") + mocha_exporter_path = ( + mocha_install_dir / "mochaexport.py") + elif platform.system().lower() == "linux": + mocha_python_path = ( + mocha_install_dir / "python" / "bin" / "python3") + mocha_exporter_path = ( + mocha_install_dir / "python" / "mochaexport.py") + else: + msg = f"Unsupported platform: {platform.system()}" + raise NotImplementedError(msg) + + context.data["mocha_python_path"] = mocha_python_path + context.data["mocha_exporter_path"] = mocha_exporter_path + + self.log.info("Collected Mocha Pro executable path: %s", + mocha_executable_path) + self.log.info("Collected Mocha Pro python executable path: %s", + mocha_python_path) + self.log.info("Collected Mocha Pro python export script path: %s", + mocha_exporter_path) diff --git a/client/ayon_mocha/plugins/publish/collect_mocha_project.py b/client/ayon_mocha/plugins/publish/collect_mocha_project.py new file mode 100644 index 0000000..c9402b9 --- /dev/null +++ b/client/ayon_mocha/plugins/publish/collect_mocha_project.py @@ -0,0 +1,32 @@ +"""Collect the current Mocha Pro project.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +import pyblish.api +from mocha.project import get_current_project + +if TYPE_CHECKING: + from logging import Logger + + +class CollectMochaProject(pyblish.api.ContextPlugin): + """Inject the current working file into context. + + Foo batr baz. + """ + + order = pyblish.api.CollectorOrder - 0.5 + label = "Collect Mocha Pro Project" + hosts: ClassVar[list[str]] = ["mochapro"] + log: Logger + + def process(self, context: pyblish.api.Context) -> None: + """Inject the current working file.""" + context.data["project"] = get_current_project() + current_file = context.data["project"].project_file + context.data["currentFile"] = current_file + if not current_file: + self.log.warning( + "Current file is not saved. Save the file before continuing." + ) diff --git a/client/ayon_mocha/plugins/publish/collect_shapes.py b/client/ayon_mocha/plugins/publish/collect_shapes.py new file mode 100644 index 0000000..3245179 --- /dev/null +++ b/client/ayon_mocha/plugins/publish/collect_shapes.py @@ -0,0 +1,103 @@ +"""Collect layers for shape export.""" +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING, ClassVar + +import pyblish.api +from ayon_core.pipeline import KnownPublishError +from ayon_core.pipeline.create import get_product_name +from ayon_mocha.api.lib import get_shape_exporters + +if TYPE_CHECKING: + from logging import Logger + + from ayon_core.pipeline.create import CreateContext + from mocha.project import Layer, Project + + +class CollectShapes(pyblish.api.InstancePlugin): + """Collect trackpoint data.""" + label = "Collect Shape Data" + order = pyblish.api.CollectorOrder - 0.45 + hosts: ClassVar[list[str]] = ["mochapro"] + families: ClassVar[list[str]] = ["matteshapes"] + log: Logger + + + @staticmethod + def new_product_name( + create_context: CreateContext, + layer_name: str, + product_type: str, + variant: str) -> str: + """Return the new product name.""" + sanitized_layer_name = layer_name.replace(" ", "_") + variant = f"{sanitized_layer_name}{variant.capitalize()}" + + return get_product_name( + project_name=create_context.project_name, + task_name=create_context.get_current_task_name(), + task_type=create_context.get_current_task_type(), + host_name=create_context.host_name, + product_type=product_type, + variant=variant, + ) + + def process(self, instance: pyblish.api.Instance) -> None: + """Process the instance.""" + # copy creator settings to the instance itself + creator_attrs = instance.data["creator_attributes"] + registered_exporters = get_shape_exporters() + selected_exporters = [ + exporter + for exporter in registered_exporters + if exporter.id in creator_attrs["exporter"] + ] + + instance.data["use_exporters"] = selected_exporters + project: Project = instance.context.data["project"] + layers: list[Layer] = [] + if creator_attrs["layer_mode"] == "selected": + layers.extend( + project.layers[selected_layer_idx] + for selected_layer_idx in creator_attrs["layers"] + ) + elif creator_attrs["layer_mode"] == "all": + layers = project.layers + else: + msg = f"Invalid layer mode: {creator_attrs['layer_mode']}" + raise KnownPublishError(msg) + + for layer in layers: + new_instance = instance.context.create_instance( + f"{instance.name} - {layer.name}" + ) + for k, v in instance.data.items(): + # this is needed because the data is not always + # "deepcopyable". + try: + new_instance.data[k] = deepcopy(v) + except TypeError: # noqa: PERF203 + new_instance.data[k] = v + + # new_instance.data = instance.data + new_instance.data["label"] = f"{instance.name} ({layer.name})" + new_instance.data["name"] = f"{instance.name}_{layer.name}" + new_instance.data["productName"] = self.new_product_name( + instance.context.data["create_context"], + layer.name, + instance.data["productType"], + instance.data["variant"], + ) + self.set_layer_data_on_instance(new_instance, layer) + if layers: + instance.context.remove(instance) + + + def set_layer_data_on_instance( + self, instance: pyblish.api.Instance, layer: Layer) -> None: + """Set data on instance.""" + instance.data["layer"] = layer + instance.data["frameStart"] = layer.in_point() + instance.data["frameEnd"] = layer.out_point() diff --git a/client/ayon_mocha/plugins/publish/export_shapes.py b/client/ayon_mocha/plugins/publish/export_shapes.py new file mode 100644 index 0000000..47397a1 --- /dev/null +++ b/client/ayon_mocha/plugins/publish/export_shapes.py @@ -0,0 +1,262 @@ +"""Extract tracking points from Mocha.""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar, List, Optional + +import clique +from ayon_core.pipeline import KnownPublishError, publish +from ayon_mocha.api.lib import ExporterProcessInfo +from ayon_mocha.api.pipeline import SHAPE_EXPORTERS_REPRESENTATION_NAME_MAPPING +from mocha.project import Layer, Project, View + +if TYPE_CHECKING: + from logging import Logger + + import pyblish.api + from ayon_mocha.api.lib import ExporterInfo + +EXTENSION_PATTERN = re.compile(r"(?P.+)\(\*\.(?P\w+)\)") + + +class ExportShape(publish.Extractor): + """Export shapes.""" + + label = "Export Shapes" + families: ClassVar[list[str]] = ["matteshapes"] + log: Logger + + def process(self, instance: pyblish.api.Instance) -> None: + """Process the instance.""" + dir_path = Path(self.staging_dir(instance)) + project: Project = instance.context.data["project"] + layer: Layer = instance.data["layer"] + + process_info = ExporterProcessInfo( + mocha_python_path=instance.context.data["mocha_python_path"], + mocha_exporter_path=instance.context.data["mocha_exporter_path"], + current_project_path=instance.context.data["currentFile"], + staging_dir=dir_path, + options={} + ) + + outputs = self.export( + instance.data["productName"], + project, + instance.data["use_exporters"], + layer, + process_info, + ) + + representations = self.process_outputs_to_representations( + outputs, instance) + + instance.data.setdefault( + "representations", []).extend(representations) + + self.log.debug(instance.data["representations"]) + + + def process_outputs_to_representations( + self, outputs: list[dict], + instance: pyblish.api.Instance) -> list[dict]: + """Process the output to representations.""" + representations = [] + staging_dir = Path(self.staging_dir(instance)) + + for output in outputs: + # if there are multiple files in one representation + # we need to check if it is sequence or not as current + # integration does not support multiple files that are not + # in sequences. + repre_name = self._exporter_name_to_representation_name( + output["name"]) + + cols, rems = clique.assemble(output["files"]) + if rems and cols: + # there are both sequences and single files + if cols > 1: + # the extractor produced multiple sequences + # and single files. This is not supported now + # due to the complexity. + msg = ("The exporter produced multiple sequences " + "and single files. This is not supported.") + raise KnownPublishError(msg) + output_files = cols[0] + for reminder in rems: + self.add_to_resources( + Path(self.staging_dir(instance)) / reminder, instance) + representations.append({ + "name": repre_name, + "ext": output["ext"], + "files": output_files, + "stagingDir": output["stagingDir"], + "outputName": output["outputName"], + }) + if rems and not cols: + if len(rems) > 1: + # if there are only non-sequence files + for reminder in rems: + self.add_to_resources( + Path(self.staging_dir(instance)) / reminder, instance) # noqa: E501 + manifest_file = self._create_manifest_file( + staging_dir, rems, repre_name) + representations.append({ + "name": repre_name, + "ext": output["ext"], + "files": manifest_file, + "stagingDir": output["stagingDir"], + "outputName": output["outputName"], + }) + else: + # if there is only one non-sequence file + representations.append({ + "name": repre_name, + "ext": output["ext"], + "files": rems[0], + "stagingDir": output["stagingDir"], + "outputName": output["outputName"], + }) + if cols and not rems: + # if there are only sequences + if cols > 1: + # the extractor produced multiple sequences + # and single files. This is not supported now + # due to the complexity. + msg = ("The exporter produced multiple sequences " + "and single files. This is not supported.") + raise KnownPublishError(msg) + representations.append({ + "name": repre_name, + "ext": output["ext"], + "files": list(cols[0]), + "stagingDir": output["stagingDir"], + "outputName": output["outputName"], + }) + return representations + + @staticmethod + def _create_manifest_file( + staging_dir: Path, files: list[str], repre_name: str) -> str: + """Create a manifest file. + + This will put all the files to a manifest file that + will be used as a representation. This is because the + current integration does not support multiple files + that are not in sequences. + + Args: + staging_dir (Path): staging directory. + files (list[str]): list of files. + repre_name (str): representation name. + + """ + file_name = f"{repre_name}.manifest" + manifest_file = staging_dir / file_name + with open(manifest_file, "w") as file: + for file_path in files: + file.write(f"{file_path}\n") + return file_name + + def export( + self, + product_name: str, + project: Project, + exporters: list[ExporterInfo], + layer: Layer, + process_info: ExporterProcessInfo + ) -> list[dict]: + """Export the instance. + + This is using in-process export but since the export + times are pretty fast, it's easier and probably + faster than using the external export. + + Args: + product_name (str): used for naming the resulting + files. + project (Project): Mocha project. + exporters (list[ExporterInfo]): exporters to use. + layer (Layer): layer to export. + process_info (ExporterProcessInfo): process information. + + """ + views = [view_info.name for view_info in project.views] + views_to_export: set[View] = { + View(num) + for num, view_info in enumerate(project.views) + if view_info.name in views or view_info.abbr in views + } + + output: list[dict] = [] + for exporter_info in exporters: + exporter_name = exporter_info.label + if not exporter_name: + msg = ("Cannot get exporter name " + f"from {exporter_info.label} exporter.") + raise KnownPublishError(msg) + + ext = self._get_extension(exporter_info) + if not ext: + msg = ("Cannot get extension " + f"from {exporter_info.label} exporter.") + raise KnownPublishError(msg) + + exporter_short_hash = exporter_info.id[:8] + file_name = f"{product_name}_{exporter_short_hash}.{ext}" + + tracking_file_path = ( + process_info.staging_dir / file_name + ) + # this is for some reason needed to pass it to `do_render()` + views_typed: List[View] = list(views_to_export) + layers_typed: List[Layer] = [layer] + result = exporter_info.exporter.do_export( + project, + layers_typed, + tracking_file_path.as_posix(), + views_typed + ) + + if not result: + msg = f"Export failed for {exporter_name}." + raise KnownPublishError(msg) + + output_files = [] + for k, v in result.items(): + with open(k, "wb") as file: + file.write(v) + output_files.append(Path(k).name) + + output.append({ + "name": exporter_info.label, + "ext": ext, + "files": output_files, + "stagingDir": process_info.staging_dir.as_posix(), + "outputName": exporter_short_hash, + }) + + return output + + def add_to_resources( + self, path: Path, instance: pyblish.api.Instance) -> None: + """Add the path to the resources.""" + self.log.debug("Adding to resources: %s", path) + + publish_dir_path = Path(instance.data["publishDir"]) + instance.data["transfers"].append( + [path.as_posix(), (publish_dir_path / path.name).as_posix()]) + + + @staticmethod + def _get_extension(exporter_info: ExporterInfo) -> Optional[str]: + """Get the extension of the exporter.""" + match = re.search(EXTENSION_PATTERN, exporter_info.label) + return match["ext"] if match else None + + def _exporter_name_to_representation_name( + self, exporter_name: str) -> str: + """Convert the exporter name to representation name.""" + return SHAPE_EXPORTERS_REPRESENTATION_NAME_MAPPING.get( + exporter_name, exporter_name) diff --git a/client/ayon_mocha/plugins/publish/validate_layers_and_exporters.py b/client/ayon_mocha/plugins/publish/validate_layers_and_exporters.py new file mode 100644 index 0000000..1140373 --- /dev/null +++ b/client/ayon_mocha/plugins/publish/validate_layers_and_exporters.py @@ -0,0 +1,61 @@ +"""Validate layers and exportes.""" +from __future__ import annotations + +import inspect +from typing import TYPE_CHECKING, ClassVar + +import pyblish.api +from ayon_core.pipeline import PublishValidationError + +if TYPE_CHECKING: + from logging import Logger + + +class ValidateLayersAndExporters(pyblish.api.InstancePlugin): + """Validate layers and exporters set.""" + + order = pyblish.api.Validator.order + 0.1 + label = "Validate Exporters and Layers" + hosts: ClassVar[list[str]] = ["mochapro"] + families: ClassVar[list[str]] = ["matteshapes", "trackpoints"] + log: Logger + + def process(self, instance: pyblish.api.Instance) -> None: + """Process all the trackpoints.""" + self.log.debug("Validating layers and exporters") + if not instance.data.get("layer"): + msg = ( + f"No layers set for instance {instance.name}" + ) + raise PublishValidationError( + msg, description=self._missing_layer_description()) + + if not instance.data.get("use_exporters"): + msg = ( + f"No exporters set for instance {instance.name}" + ) + raise PublishValidationError( + msg, description=self._missing_exporter()) + + @classmethod + def _missing_layer_description(cls) -> str: + """Return the description for missing layer.""" + return inspect.cleandoc( + """ + ### Issue + + The instance doesn't have layers set. Please select the layer + in the publisher,or set the layer mode to "All layers". + """ + ) + + @classmethod + def _missing_exporter(cls) -> str: + """Return the description for missing layer.""" + return inspect.cleandoc( + """ + ### Issue + + You need to select at least one exporter. + """ + )