diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 58476e7900..5beb3ddd6d 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -1,7 +1,8 @@ import sys -import json import re import os +import json +import uuid import contextlib from typing import List, Dict, Any from opentimelineio import opentime @@ -40,6 +41,36 @@ self.pype_timeline_name = "OpenPypeTimeline" +def get_timeline_media_pool_item(timeline, root=None) -> object: + """Return MediaPoolItem from Timeline + + + Args: + timeline (resolve.Timeline): timeline object + root (resolve.Folder): root folder / bin object + + Returns: + resolve.MediaPoolItem: media pool item from timeline + """ + + # Due to limitations in the Resolve API we can't get + # the media pool item directly from the timeline. + # We can find it by name, however names are not + # enforced to be unique across bins. So, we give it + # unique name. + original_name = timeline.GetName() + identifier = str(uuid.uuid4().hex) + try: + timeline.SetName(identifier) + for item in iter_all_media_pool_clips(root=root): + if item.GetName() != identifier: + continue + return item + finally: + # Revert to original name + timeline.SetName(original_name) + + @contextlib.contextmanager def maintain_current_timeline(to_timeline: object, from_timeline: object = None): @@ -961,9 +992,14 @@ def get_reformated_path(path, padded=False, first=False): return path -def iter_all_media_pool_clips(): - """Recursively iterate all media pool clips in current project""" - root = get_current_project().GetMediaPool().GetRootFolder() +def iter_all_media_pool_clips(root=None): + """Recursively iterate all media pool clips in current project + + Args: + root (Optional[resolve.Folder]): root folder / bin object. + When None, defaults to media pool root folder. + """ + root = root or get_current_project().GetMediaPool().GetRootFolder() queue = [root] for folder in queue: for clip in folder.GetClipList(): diff --git a/client/ayon_resolve/api/pipeline.py b/client/ayon_resolve/api/pipeline.py index 05d2c9bcd1..d68ba228ef 100644 --- a/client/ayon_resolve/api/pipeline.py +++ b/client/ayon_resolve/api/pipeline.py @@ -156,6 +156,13 @@ def ls(): continue data = json.loads(data) + # treat data as container + # There might be cases where clip's metadata are having additional + # because it needs to store 'load' and 'publish' data. In that case + # we need to get only 'load' data + if data.get("load"): + data = data["load"] + # If not all required data, skip it required = ['schema', 'id', 'loader', 'representation'] if not all(key in data for key in required): diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index 12b10fe441..671285604e 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -5,6 +5,7 @@ import qargparse from qtpy import QtWidgets, QtCore +from ayon_core.pipeline.constants import AVALON_INSTANCE_ID from ayon_core.settings import get_current_project_settings from ayon_core.pipeline import ( LegacyCreator, @@ -903,6 +904,35 @@ def _create_parents(self): self.parents.append(parent) +def get_editorial_publish_data( + folder_path, + product_name, + version=None +) -> dict: + """Get editorial publish data from context. + + Args: + folder_path (str): Folder path where editorial package is located. + product_name (str): Editorial product name. + version (Optional[str]): Editorial product version. Defaults to None. + + Returns: + dict: Editorial publish data. + """ + data = { + "id": AVALON_INSTANCE_ID, + "productType": "editorial_pkg", + "productName": product_name, + "folderPath": folder_path, + "active": True, + } + + if version: + data["version"] = version + + return data + + def get_representation_files(representation): anatomy = Anatomy() files = [] diff --git a/client/ayon_resolve/plugins/create/create_editorial_package.py b/client/ayon_resolve/plugins/create/create_editorial_package.py new file mode 100644 index 0000000000..67ae9c3edb --- /dev/null +++ b/client/ayon_resolve/plugins/create/create_editorial_package.py @@ -0,0 +1,31 @@ +import json +from ayon_core.pipeline.create.legacy_create import LegacyCreator + +from ayon_resolve.api import lib + + +class CreateEditorialPackage(LegacyCreator): + """Create Editorial Package.""" + + name = "editorial_pkg" + label = "Editorial Package" + product_type = "editorial_pkg" + icon = "camera" + defaults = ["Main"] + + def process(self): + """Process the creation of the editorial package.""" + current_timeline = lib.get_current_timeline() + + if not current_timeline: + raise RuntimeError("Make sure to have an active current timeline.") + + timeline_media_pool_item = lib.get_timeline_media_pool_item( + current_timeline + ) + + publish_data = {"publish": self.data} + + timeline_media_pool_item.SetMetadata( + lib.pype_tag_name, json.dumps(publish_data) + ) diff --git a/client/ayon_resolve/plugins/load/load_editorial_package.py b/client/ayon_resolve/plugins/load/load_editorial_package.py index 234e7b7f71..4d65365119 100644 --- a/client/ayon_resolve/plugins/load/load_editorial_package.py +++ b/client/ayon_resolve/plugins/load/load_editorial_package.py @@ -1,11 +1,15 @@ +import json from pathlib import Path +import random from ayon_core.pipeline import ( + AVALON_CONTAINER_ID, load, get_representation_path, ) from ayon_resolve.api import lib +from ayon_resolve.api.plugin import get_editorial_publish_data class LoadEditorialPackage(load.LoaderPlugin): @@ -32,21 +36,124 @@ def load(self, context, name, namespace, data): project = lib.get_current_project() media_pool = project.GetMediaPool() + folder_path = context["folder"]["path"] # create versioned bin for editorial package version_name = context["version"]["name"] - bin_name = f"{name}_{version_name}" - lib.create_bin(bin_name) + loaded_bin = lib.create_bin(f"{folder_path}/{name}/{version_name}") + # make timeline unique name based on folder path + folder_path_name = folder_path.replace("/", "_").lstrip("_") + loaded_timeline_name = ( + f"{folder_path_name}_{name}_{version_name}_timeline") import_options = { - "timelineName": "Editorial Package Timeline", + "timelineName": loaded_timeline_name, "importSourceClips": True, "sourceClipsPath": search_folder_path.as_posix(), } + # import timeline from otio file timeline = media_pool.ImportTimelineFromFile(files, import_options) + + # get timeline media pool item for metadata update + timeline_media_pool_item = lib.get_timeline_media_pool_item( + timeline, loaded_bin + ) + + # Update the metadata + clip_data = self._get_container_data( + context, data) + + timeline_media_pool_item.SetMetadata( + lib.pype_tag_name, json.dumps(clip_data) + ) + + # set clip color based on random choice + clip_color = self.get_random_clip_color() + timeline_media_pool_item.SetClipColor(clip_color) + + # TODO: there are two ways to import timeline resources (representation + # and resources folder) but Resolve seems to ignore any of this + # since it is importing sources automatically. But we might need + # to at least set some metadata to those loaded media pool items print("Timeline imported: ", timeline) def update(self, container, context): - # TODO: implement update method in future - pass + timeline_mp_clip = container["_item"] + timeline_mp_clip.SetMetadata(lib.pype_tag_name, "") + + self.load( + context, + context["product"]["name"], + container["namespace"], + container + ) + + def _get_container_data( + self, + context: dict, + data: dict + ) -> dict: + """Return metadata related to the representation and version.""" + + # add additional metadata from the version to imprint AYON knob + version_entity = context["version"] + + for key in ("_item", "name"): + data.pop(key, None) # remove unnecessary key from the data if it exists + + data = { + "load": data, + } + + # add version attributes to the load data + data["load"].update( + version_entity["attrib"] + ) + + # add variables related to version context + data["load"].update( + { + "schema": "ayon:container-3.0", + "id": AVALON_CONTAINER_ID, + "loader": str(self.__class__.__name__), + "author": version_entity["data"]["author"], + "representation": context["representation"]["id"], + "version": version_entity["version"], + } + ) + + # add publish data for streamline publishing + data["publish"] = get_editorial_publish_data( + folder_path=context["folder"]["path"], + product_name=context["product"]["name"], + version=version_entity["version"], + ) + + return data + + def get_random_clip_color(self): + """Return clip color.""" + + # list of all available davinci resolve clip colors + colors = [ + "Orange", + "Apricot" + "Yellow", + "Lime", + "Olive", + "Green", + "Teal", + "Navy", + "Blue", + "Purple", + "Violet", + "Pink", + "Tan", + "Beige", + "Brown", + "Chocolate", + ] + + # return one of the colors based on random position + return random.choice(colors)