diff --git a/client/ayon_flame/api/__init__.py b/client/ayon_flame/api/__init__.py index bc34c90..23ecaa9 100644 --- a/client/ayon_flame/api/__init__.py +++ b/client/ayon_flame/api/__init__.py @@ -56,10 +56,11 @@ FlameMenuUniversal ) from .plugin import ( - Creator, PublishableClip, ClipLoader, - OpenClipSolver + OpenClipSolver, + FlameCreator, + HiddenFlameCreator, ) from .workio import ( open_file, @@ -141,6 +142,8 @@ "PublishableClip", "ClipLoader", "OpenClipSolver", + "FlameCreator", + "HiddenFlameCreator", # workio "open_file", diff --git a/client/ayon_flame/api/menu.py b/client/ayon_flame/api/menu.py index 5c3ace4..0fc5460 100644 --- a/client/ayon_flame/api/menu.py +++ b/client/ayon_flame/api/menu.py @@ -183,11 +183,16 @@ def build_menu(self): menu = deepcopy(self.menu) - menu['actions'].append({ - "name": "1 - Create...", - "execute": lambda x: callback_selection( - x, self.tools_helper.show_creator) - }) + menu['actions'].append( + { + "name": "1 - Create...", + "execute": lambda x: callback_selection( + x, host_tools.show_publisher( + tab="create", parent=_get_main_window() + ) + ), + } + ) menu["actions"].append( { "name": "2 - Publish...", diff --git a/client/ayon_flame/api/plugin.py b/client/ayon_flame/api/plugin.py index f5223df..f81c7dd 100644 --- a/client/ayon_flame/api/plugin.py +++ b/client/ayon_flame/api/plugin.py @@ -3,330 +3,64 @@ import shutil from copy import deepcopy from xml.etree import ElementTree as ET -from pprint import pformat import qargparse -from qtpy import QtCore, QtWidgets -from ayon_core import style from ayon_core.lib import Logger, StringTemplate -from ayon_core.pipeline import LegacyCreator, LoaderPlugin +from ayon_core.pipeline import LoaderPlugin, HiddenCreator +from ayon_core.pipeline import Creator from ayon_core.pipeline.colorspace import get_remapped_colorspace_to_native from ayon_core.settings import get_current_project_settings -from . import constants from . import lib as flib -from . import pipeline as fpipeline -log = Logger.get_logger(__name__) - - -class CreatorWidget(QtWidgets.QDialog): - - # output items - items = dict() - _results_back = None - - def __init__(self, name, info, ui_inputs, parent=None): - super(CreatorWidget, self).__init__(parent) - - self.setObjectName(name) - - self.setWindowFlags( - QtCore.Qt.Window - | QtCore.Qt.CustomizeWindowHint - | QtCore.Qt.WindowTitleHint - | QtCore.Qt.WindowCloseButtonHint - | QtCore.Qt.WindowStaysOnTopHint - ) - self.setWindowTitle(name or "AYON Creator Input") - self.resize(500, 700) - - # Where inputs and labels are set - self.content_widget = [QtWidgets.QWidget(self)] - top_layout = QtWidgets.QFormLayout(self.content_widget[0]) - top_layout.setObjectName("ContentLayout") - top_layout.addWidget(Spacer(5, self)) - - # first add widget tag line - top_layout.addWidget(QtWidgets.QLabel(info)) - - # main dynamic layout - self.scroll_area = QtWidgets.QScrollArea(self, widgetResizable=True) - self.scroll_area.setVerticalScrollBarPolicy( - QtCore.Qt.ScrollBarAsNeeded) - self.scroll_area.setVerticalScrollBarPolicy( - QtCore.Qt.ScrollBarAlwaysOn) - self.scroll_area.setHorizontalScrollBarPolicy( - QtCore.Qt.ScrollBarAlwaysOff) - self.scroll_area.setWidgetResizable(True) - - self.content_widget.append(self.scroll_area) - - scroll_widget = QtWidgets.QWidget(self) - in_scroll_area = QtWidgets.QVBoxLayout(scroll_widget) - self.content_layout = [in_scroll_area] - - # add preset data into input widget layout - self.items = self.populate_widgets(ui_inputs) - self.scroll_area.setWidget(scroll_widget) - - # Confirmation buttons - btns_widget = QtWidgets.QWidget(self) - btns_layout = QtWidgets.QHBoxLayout(btns_widget) - - cancel_btn = QtWidgets.QPushButton("Cancel") - btns_layout.addWidget(cancel_btn) - - ok_btn = QtWidgets.QPushButton("Ok") - btns_layout.addWidget(ok_btn) - - # Main layout of the dialog - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.setContentsMargins(10, 10, 10, 10) - main_layout.setSpacing(0) - - # adding content widget - for w in self.content_widget: - main_layout.addWidget(w) - - main_layout.addWidget(btns_widget) - - ok_btn.clicked.connect(self._on_ok_clicked) - cancel_btn.clicked.connect(self._on_cancel_clicked) - - self.setStyleSheet(style.load_stylesheet()) - - @classmethod - def set_results_back(cls, value): - cls._results_back = value - - @classmethod - def get_results_back(cls): - return cls._results_back - - def _on_ok_clicked(self): - log.debug("ok is clicked: {}".format(self.items)) - results_back = self._values(self.items) - self.set_results_back(results_back) - self.close() - - def _on_cancel_clicked(self): - self.set_results_back(None) - self.close() - - def showEvent(self, event): - self.set_results_back(None) - super(CreatorWidget, self).showEvent(event) - - def _values(self, data, new_data=None): - new_data = new_data or dict() - for k, v in data.items(): - new_data[k] = { - "target": None, - "value": None - } - if v["type"] == "dict": - new_data[k]["target"] = v["target"] - new_data[k]["value"] = self._values(v["value"]) - if v["type"] == "section": - new_data.pop(k) - new_data = self._values(v["value"], new_data) - elif getattr(v["value"], "currentText", None): - new_data[k]["target"] = v["target"] - new_data[k]["value"] = v["value"].currentText() - elif getattr(v["value"], "isChecked", None): - new_data[k]["target"] = v["target"] - new_data[k]["value"] = v["value"].isChecked() - elif getattr(v["value"], "value", None): - new_data[k]["target"] = v["target"] - new_data[k]["value"] = v["value"].value() - elif getattr(v["value"], "text", None): - new_data[k]["target"] = v["target"] - new_data[k]["value"] = v["value"].text() - - return new_data - - def camel_case_split(self, text): - matches = re.finditer( - '.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)', text) - return " ".join([str(m.group(0)).capitalize() for m in matches]) - - def create_row(self, layout, type_name, text, **kwargs): - # get type attribute from qwidgets - attr = getattr(QtWidgets, type_name) - - # convert label text to normal capitalized text with spaces - label_text = self.camel_case_split(text) - - # assign the new text to label widget - label = QtWidgets.QLabel(label_text) - label.setObjectName("LineLabel") - - # create attribute name text strip of spaces - attr_name = text.replace(" ", "") - - # create attribute and assign default values - setattr( - self, - attr_name, - attr(parent=self)) - - # assign the created attribute to variable - item = getattr(self, attr_name) - for func, val in kwargs.items(): - if getattr(item, func): - func_attr = getattr(item, func) - func_attr(val) - - # add to layout - layout.addRow(label, item) - - return item - - def populate_widgets(self, data, content_layout=None): - """ - Populate widget from input dict. - - Each plugin has its own set of widget rows defined in dictionary - each row values should have following keys: `type`, `target`, - `label`, `order`, `value` and optionally also `toolTip`. - - Args: - data (dict): widget rows or organized groups defined - by types `dict` or `section` - content_layout (QtWidgets.QFormLayout)[optional]: used when nesting - - Returns: - dict: redefined data dict updated with created widgets - - """ - - content_layout = content_layout or self.content_layout[-1] - # fix order of process by defined order value - ordered_keys = list(data.keys()) - for k, v in data.items(): - try: - # try removing a key from index which should - # be filled with new - ordered_keys.pop(v["order"]) - except IndexError: - pass - # add key into correct order - ordered_keys.insert(v["order"], k) - - # process ordered - for k in ordered_keys: - v = data[k] - tool_tip = v.get("toolTip", "") - if v["type"] == "dict": - self.content_layout.append(QtWidgets.QWidget(self)) - content_layout.addWidget(self.content_layout[-1]) - self.content_layout[-1].setObjectName("sectionHeadline") - - headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) - headline.addWidget(Spacer(20, self)) - headline.addWidget(QtWidgets.QLabel(v["label"])) - - # adding nested layout with label - self.content_layout.append(QtWidgets.QWidget(self)) - self.content_layout[-1].setObjectName("sectionContent") - - nested_content_layout = QtWidgets.QFormLayout( - self.content_layout[-1]) - nested_content_layout.setObjectName("NestedContentLayout") - content_layout.addWidget(self.content_layout[-1]) - - # add nested key as label - data[k]["value"] = self.populate_widgets( - v["value"], nested_content_layout) - - if v["type"] == "section": - self.content_layout.append(QtWidgets.QWidget(self)) - content_layout.addWidget(self.content_layout[-1]) - self.content_layout[-1].setObjectName("sectionHeadline") - - headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) - headline.addWidget(Spacer(20, self)) - headline.addWidget(QtWidgets.QLabel(v["label"])) - - # adding nested layout with label - self.content_layout.append(QtWidgets.QWidget(self)) - self.content_layout[-1].setObjectName("sectionContent") - - nested_content_layout = QtWidgets.QFormLayout( - self.content_layout[-1]) - nested_content_layout.setObjectName("NestedContentLayout") - content_layout.addWidget(self.content_layout[-1]) - - # add nested key as label - data[k]["value"] = self.populate_widgets( - v["value"], nested_content_layout) - - elif v["type"] == "QLineEdit": - data[k]["value"] = self.create_row( - content_layout, "QLineEdit", v["label"], - setText=v["value"], setToolTip=tool_tip) - elif v["type"] == "QComboBox": - data[k]["value"] = self.create_row( - content_layout, "QComboBox", v["label"], - addItems=v["value"], setToolTip=tool_tip) - elif v["type"] == "QCheckBox": - data[k]["value"] = self.create_row( - content_layout, "QCheckBox", v["label"], - setChecked=v["value"], setToolTip=tool_tip) - elif v["type"] == "QSpinBox": - data[k]["value"] = self.create_row( - content_layout, "QSpinBox", v["label"], - setMaximum=100000, - setMinimum=0, - setValue=v["value"], - setToolTip=tool_tip, - ) - return data +log = Logger.get_logger(__name__) -class Spacer(QtWidgets.QWidget): - def __init__(self, height, *args, **kwargs): - super(self.__class__, self).__init__(*args, **kwargs) - self.setFixedHeight(height) +class HiddenFlameCreator(HiddenCreator): + """HiddenCreator class wrapper + """ + settings_category = "flame" - real_spacer = QtWidgets.QWidget(self) - real_spacer.setObjectName("Spacer") - real_spacer.setFixedHeight(height) + def collect_instances(self): + pass - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(real_spacer) + def update_instances(self, update_list): + pass - self.setLayout(layout) + def remove_instances(self, instances): + pass -class Creator(LegacyCreator): +class FlameCreator(Creator): """Creator class wrapper """ - clip_color = constants.COLOR_MAP["purple"] - rename_index = None + settings_category = "flame" def __init__(self, *args, **kwargs): super(Creator, self).__init__(*args, **kwargs) self.presets = get_current_project_settings()[ "flame"]["create"].get(self.__class__.__name__, {}) - # adding basic current context flame objects + def create(self, product_name, instance_data, pre_create_data): + """Prepare data for new instance creation. + + Args: + product_name(str): Product name of created instance. + instance_data(dict): Base data for instance. + pre_create_data(dict): Data based on pre creation attributes. + Those may affect how creator works. + """ + # adding basic current context resolve objects self.project = flib.get_current_project() self.sequence = flib.get_current_sequence(flib.CTX.selection) - if (self.options or {}).get("useSelection"): - self.selected = flib.get_sequence_segments(self.sequence, True) - else: - self.selected = flib.get_sequence_segments(self.sequence) - - def create_widget(self, *args, **kwargs): - widget = CreatorWidget(*args, **kwargs) - widget.exec_() - return widget.get_results_back() + selected = pre_create_data.get("use_selection", False) + self.selected = flib.get_sequence_segments( + self.sequence, + selected=selected + ) class PublishableClip: @@ -358,9 +92,8 @@ class PublishableClip: rename_default = False hierarchy_default = "{_folder_}/{_sequence_}/{_track_}" clip_name_default = "shot_{_trackIndex_:0>3}_{_clipIndex_:0>4}" - review_track_default = "[ none ]" - base_product_name_default = "[ track name ]" - base_product_type_default = "plate" + review_source_default = None + base_product_variant_default = "" count_from_default = 10 count_steps_default = 10 vertical_sync_default = False @@ -371,18 +104,24 @@ class PublishableClip: retimed_handles_default = True retimed_framerange_default = True - def __init__(self, segment, **kwargs): - self.rename_index = kwargs["rename_index"] - self.product_type = kwargs["productType"] - self.log = kwargs["log"] + def __init__(self, + segment, + pre_create_data=None, + data=None, + product_type=None, + rename_index=None, + log=None, + ): + self.rename_index = rename_index + self.product_type = product_type + self.log = log + self.pre_create_data = pre_create_data or {} # get main parent objects self.current_segment = segment sequence_name = flib.get_current_sequence([segment]).name.get_value() self.sequence_name = str(sequence_name).replace(" ", "_") - self.clip_data = flib.get_segment_attributes(segment) - self.log.debug(f"clip_data: {pformat(self.clip_data)}") # segment (clip) main attributes self.cs_name = self.clip_data["segment_name"] @@ -400,16 +139,13 @@ def __init__(self, segment, **kwargs): .replace("*", f"noname{self.track_index}") ) - if kwargs.get("basicProductData"): - self.marker_data.update(kwargs["basicProductData"]) - # add publish attribute to marker data self.marker_data.update({"publish": True}) - # adding ui inputs if any - self.ui_inputs = kwargs.get("ui_inputs", {}) + # adding input data if any + if data: + self.marker_data.update(data) - self.log.info(f"Inside of plugin: {self.marker_data}") # populate default data before we get other attributes self._populate_segment_default_data() @@ -419,6 +155,11 @@ def __init__(self, segment, **kwargs): # create parents with correct types self._create_parents() + @classmethod + def restore_all_caches(cls): + cls.vertical_clip_match = {} + cls.vertical_clip_used = {} + def convert(self): # solve segment data and add them to marker data @@ -426,8 +167,8 @@ def convert(self): # if track name is in review track name and also if driving track name # is not in review track name: skip tag creation - if (self.track_name in self.review_layer) and ( - self.driving_layer not in self.review_layer): + if (self.track_name in self.reviewable_source) and ( + self.driving_layer not in self.reviewable_source): return # deal with clip name @@ -459,14 +200,6 @@ def convert(self): } }) - if self.marker_data["heroTrack"] and self.review_layer: - self.marker_data["reviewTrack"] = self.review_layer - else: - self.marker_data["reviewTrack"] = None - - # create pype tag on track_item and add data - fpipeline.imprint(self.current_segment, self.marker_data) - return self.current_segment def _populate_segment_default_data(self): @@ -491,56 +224,60 @@ def _populate_attributes(self): self.shot_num = self.cs_index self.log.debug(f"____ self.shot_num: {self.shot_num}") - # ui_inputs data or default values if gui was not used - self.rename = self.ui_inputs.get( - "clipRename", {}).get("value") or self.rename_default - self.use_shot_name = self.ui_inputs.get( - "useShotName", {}).get("value") or self.use_shot_name_default - self.clip_name = self.ui_inputs.get( - "clipName", {}).get("value") or self.clip_name_default - self.hierarchy = self.ui_inputs.get( - "hierarchy", {}).get("value") or self.hierarchy_default - self.hierarchy_data = self.ui_inputs.get( - "hierarchyData", {}).get("value") or \ - self.current_segment_default_data.copy() - self.index_from_segment = self.ui_inputs.get( - "segmentIndex", {}).get("value") or self.index_from_segment_default - self.count_from = self.ui_inputs.get( - "countFrom", {}).get("value") or self.count_from_default - self.count_steps = self.ui_inputs.get( - "countSteps", {}).get("value") or self.count_steps_default - self.base_product_name = self.ui_inputs.get( - "productName", {}).get("value") or self.base_product_name_default - self.base_product_type = self.ui_inputs.get( - "productType", {}).get("value") or self.base_product_type_default - self.vertical_sync = self.ui_inputs.get( - "vSyncOn", {}).get("value") or self.vertical_sync_default - self.driving_layer = self.ui_inputs.get( - "vSyncTrack", {}).get("value") or self.driving_layer_default - self.review_track = self.ui_inputs.get( - "reviewTrack", {}).get("value") or self.review_track_default - self.audio = self.ui_inputs.get( - "audio", {}).get("value") or False - self.include_handles = self.ui_inputs.get( - "includeHandles", {}).get("value") or self.include_handles_default + # Use pre-create data or default values if gui was not used + self.rename = self.pre_create_data.get( + "clipRename") or self.rename_default + self.use_shot_name = self.pre_create_data.get( + "useShotName") or self.use_shot_name_default + self.clip_name = self.pre_create_data.get( + "clipName") or self.clip_name_default + self.hierarchy = self.pre_create_data.get( + "hierarchy") or self.hierarchy_default + self.hierarchy_data = self.pre_create_data.get( + "hierarchyData") or self.current_segment_default_data.copy() + self.index_from_segment = self.pre_create_data.get( + "segmentIndex") or self.index_from_segment_default + self.count_from = self.pre_create_data.get( + "countFrom") or self.count_from_default + self.count_steps = self.pre_create_data.get( + "countSteps") or self.count_steps_default + self.base_product_variant = self.pre_create_data.get( + "clipVariant") or self.base_product_variant_default + self.base_product_type = self.product_type + self.vertical_sync = self.pre_create_data.get( + "vSyncOn") or self.vertical_sync_default + self.driving_layer = self.pre_create_data.get( + "vSyncTrack") or self.driving_layer_default + self.review_source = self.pre_create_data.get( + "reviewableSource") or self.review_source_default + self.audio = self.pre_create_data.get("audio") or False + self.include_handles = self.pre_create_data.get( + "includeHandles") or self.include_handles_default self.retimed_handles = ( - self.ui_inputs.get("retimedHandles", {}).get("value") + self.pre_create_data.get("retimedHandles") or self.retimed_handles_default ) self.retimed_framerange = ( - self.ui_inputs.get("retimedFramerange", {}).get("value") + self.pre_create_data.get("retimedFramerange") or self.retimed_framerange_default ) # build product name from layer name - if self.base_product_name == "[ track name ]": - self.base_product_name = self.track_name + if self.base_product_variant == "": + self.variant = self.track_name + else: + self.variant = self.base_product_variant # create product for publishing self.product_name = ( - self.base_product_type + self.base_product_name.capitalize() + self.base_product_type + self.variant.capitalize() ) + self.hierarchy_data = { + key: self.pre_create_data.get(key) + for key in ["folder", "episode", "sequence", "track", "shot"] + } + def _replace_hash_to_expression(self, name, text): """ Replace hash with number in correct padding. """ _spl = text.split("#") @@ -555,8 +292,12 @@ def _convert_to_marker_data(self): """ # define vertical sync attributes hero_track = True - self.review_layer = "" - if self.vertical_sync and self.track_name not in self.driving_layer: + self.reviewable_source = "" + + if ( + self.vertical_sync and + self.track_name not in self.driving_layer + ): # if it is not then define vertical sync as None hero_track = False @@ -567,19 +308,22 @@ def _convert_to_marker_data(self): hierarchy_formatting_data = {} hierarchy_data = deepcopy(self.hierarchy_data) _data = self.current_segment_default_data.copy() - if self.ui_inputs: - # adding tag metadata from ui - for _k, _v in self.ui_inputs.items(): - if _v["target"] == "tag": - self.marker_data[_k] = _v["value"] + + + if self.pre_create_data: + + # backward compatibility for reviewableSource (2024.12.02) + if "reviewTrack" in self.pre_create_data: + _value = self.marker_data.pop("reviewTrack") + self.marker_data["reviewableSource"] = _value # driving layer is set as positive match if hero_track or self.vertical_sync: # mark review layer - if self.review_track and ( - self.review_track not in self.review_track_default): + if self.review_source and ( + self.review_source != self.review_source_default): # if review layer is defined and not the same as default - self.review_layer = self.review_track + self.reviewable_source = self.review_source # shot num calculate if self.index_from_segment: @@ -596,15 +340,13 @@ def _convert_to_marker_data(self): # solve # in test to pythonic expression for _k, _v in hierarchy_data.items(): - if "#" not in _v["value"]: + if "#" not in _v: continue - hierarchy_data[ - _k]["value"] = self._replace_hash_to_expression( - _k, _v["value"]) + hierarchy_data[_k] = self._replace_hash_to_expression(_k, _v) # fill up pythonic expresisons in hierarchy data for k, _v in hierarchy_data.items(): - hierarchy_formatting_data[k] = _v["value"].format(**_data) + hierarchy_formatting_data[k] = str(_v).format(**_data) else: # if no gui mode then just pass default data hierarchy_formatting_data = hierarchy_data @@ -634,6 +376,7 @@ def _convert_to_marker_data(self): _distrib_data = deepcopy(hero_data) _distrib_data["heroTrack"] = False + # form used clip unique key data_product_name = hero_data["productName"] new_clip_name = hero_data["newClipName"] @@ -646,19 +389,21 @@ def _convert_to_marker_data(self): f">> used_names_list: {used_names_list}" ) clip_product_name = self.product_name + variant = self.variant self.log.debug( f">> clip_product_name: {clip_product_name}") # in case track name and product name is the same then add - if self.base_product_name == self.track_name: + if self.variant == self.track_name: clip_product_name = self.product_name # add track index in case duplicity of names in hero data # INFO: this is for case where hero clip product name # is the same as current clip product name - if clip_product_name == data_product_name: + if clip_product_name in data_product_name: clip_product_name = ( f"{clip_product_name}{self.track_index}") + variant = f"{variant}{self.track_index}" # in case track clip product name had been already used # then add product name with clip index @@ -674,10 +419,12 @@ def _convert_to_marker_data(self): f"{self.track_index}{self.cs_index}" ) clip_product_name = _clip_product_name + variant = f"{variant}{self.cs_index}" self.log.debug( f">> clip_product_name: {clip_product_name}") _distrib_data["productName"] = clip_product_name + _distrib_data["variant"] = variant # assign data to return hierarchy data to tag tag_hierarchy_data = _distrib_data @@ -688,6 +435,29 @@ def _convert_to_marker_data(self): # add data to return data dict self.marker_data.update(tag_hierarchy_data) + # add review track only to hero track + if hero_track and self.reviewable_source: + self.marker_data["reviewTrack"] = self.reviewable_source + else: + self.marker_data["reviewTrack"] = None + + # add only review related data if reviewable source is set + if self.reviewable_source: + review_switch = True + reviewable_source = self.reviewable_source + + if self.vertical_sync and not hero_track: + review_switch = False + reviewable_source = False + + if review_switch: + self.marker_data["review"] = True + else: + self.marker_data.pop("review", None) + + self.marker_data["reviewableSource"] = reviewable_source + + def _solve_tag_hierarchy_data(self, hierarchy_formatting_data): """ Solve marker data from hierarchy data and templates. """ # fill up clip name and hierarchy keys @@ -704,6 +474,7 @@ def _solve_tag_hierarchy_data(self, hierarchy_formatting_data): "hierarchyData": hierarchy_formatting_data, "productName": self.product_name, "productType": self.base_product_type, + "variant": self.variant, } def _convert_to_entity(self, src_type, template): @@ -718,7 +489,7 @@ def _convert_to_entity(self, src_type, template): # first collect formatting data to use for formatting template formatting_data = {} for _k, _v in self.hierarchy_data.items(): - value = _v["value"].format( + value = str(_v).format( **self.current_segment_default_data) formatting_data[_k] = value diff --git a/client/ayon_flame/api/scripts/wiretap_com.py b/client/ayon_flame/api/scripts/wiretap_com.py index 42b9257..774e470 100644 --- a/client/ayon_flame/api/scripts/wiretap_com.py +++ b/client/ayon_flame/api/scripts/wiretap_com.py @@ -9,7 +9,7 @@ import xml.dom.minidom as minidom from copy import deepcopy import datetime -from libwiretapPythonClientAPI import ( # noqa +from adsk.libwiretapPythonClientAPI import ( # noqa WireTapClientInit, WireTapClientUninit, WireTapNodeHandle, diff --git a/client/ayon_flame/otio/flame_export.py b/client/ayon_flame/otio/flame_export.py index a4bc188..0ba722b 100644 --- a/client/ayon_flame/otio/flame_export.py +++ b/client/ayon_flame/otio/flame_export.py @@ -490,7 +490,7 @@ def _get_shot_tokens_values(clip, tokens): return output -def _get_segment_attributes(segment): +def get_segment_attributes(segment): log.debug("Segment name|hidden: {}|{}".format( segment.name.get_value(), segment.hidden @@ -577,7 +577,7 @@ def create_otio_timeline(sequence): all_segments = [] for segment in track.segments: - clip_data = _get_segment_attributes(segment) + clip_data = get_segment_attributes(segment) if not clip_data: continue all_segments.append(clip_data) diff --git a/client/ayon_flame/otio/utils.py b/client/ayon_flame/otio/utils.py index 5a28263..bb425a6 100644 --- a/client/ayon_flame/otio/utils.py +++ b/client/ayon_flame/otio/utils.py @@ -89,3 +89,32 @@ def get_frame_from_filename(path): found = re.findall(FRAME_PATTERN, filename) return found.pop() if found else None + + +def get_marker_from_clip_index(otio_timeline, clip_index): + """ + Return the clip and marker data from clip index. + + Args: + otio_timeline (dict): otio timeline + clip_index (str): The clip index. + + Returns: + dict: otio clip object + + """ + import ayon_flame.api as ayfapi + + for otio_clip in otio_timeline.find_clips(): + + # Retrieve otioClip from parent context otioTimeline + # See collect_current_project + for marker in otio_clip.markers: + + if ayfapi.MARKER_NAME not in marker.name: + continue + + if marker.metadata.get("clip_index") == clip_index: + return otio_clip, marker + + return None, None diff --git a/client/ayon_flame/plugins/create/create_shot_clip.py b/client/ayon_flame/plugins/create/create_shot_clip.py index df558bf..490df86 100644 --- a/client/ayon_flame/plugins/create/create_shot_clip.py +++ b/client/ayon_flame/plugins/create/create_shot_clip.py @@ -1,51 +1,572 @@ from copy import deepcopy +import uuid + import ayon_flame.api as ayfapi +from ayon_flame.api import plugin, lib, pipeline +from ayon_flame.otio import flame_export + +from ayon_core.pipeline.create import CreatorError, CreatedInstance +from ayon_core.lib import BoolDef, EnumDef, TextDef, UILabelDef, NumberDef + + +# Used as a key by the creators in order to +# retrieve the instances data into clip markers. +_CONTENT_ID = "flame_sub_products" + + +# Shot attributes +CLIP_ATTR_DEFS = [ + EnumDef( + "fps", + items=[ + {"value": "from_selection", "label": "From selection"}, + {"value": 23.997, "label": "23.976"}, + {"value": 24, "label": "24"}, + {"value": 25, "label": "25"}, + {"value": 29.97, "label": "29.97"}, + {"value": 30, "label": "30"} + ], + label="FPS" + ), + NumberDef( + "workfileFrameStart", + default=1001, + label="Workfile start frame" + ), + NumberDef( + "handleStart", + default=0, + label="Handle start" + ), + NumberDef( + "handleEnd", + default=0, + label="Handle end" + ), + NumberDef( + "frameStart", + default=0, + label="Frame start", + disabled=True, + ), + NumberDef( + "frameEnd", + default=0, + label="Frame end", + disabled=True, + ), + NumberDef( + "clipIn", + default=0, + label="Clip in", + disabled=True, + ), + NumberDef( + "clipOut", + default=0, + label="Clip out", + disabled=True, + ), + NumberDef( + "clipDuration", + default=0, + label="Clip duration", + disabled=True, + ), + NumberDef( + "sourceIn", + default=0, + label="Media source in", + disabled=True, + ), + NumberDef( + "sourceOut", + default=0, + label="Media source out", + disabled=True, + ), + BoolDef( + "includeHandles", + label="Include handles", + default=False, + ), + BoolDef( + "retimedHandles", + label="Retimed handles", + default=True, + ), + BoolDef( + "retimedFramerange", + label="Retimed framerange", + default=True, + ), +] + + +class _FlameInstanceCreator(plugin.HiddenFlameCreator): + """Wrapper class for clip types products. + """ + + def create(self, instance_data, _): + """Return a new CreateInstance for new shot from Flame. + + Args: + instance_data (dict): global data from original instance + + Return: + CreatedInstance: The created instance object for the new shot. + """ + instance_data.update({ + "newHierarchyIntegration": True, + # Backwards compatible (Deprecated since 24/06/06) + "newAssetPublishing": True, + }) + + new_instance = CreatedInstance( + self.product_type, instance_data["productName"], instance_data, self + ) + self._add_instance_to_context(new_instance) + new_instance.transient_data["has_promised_context"] = True + return new_instance + + def update_instances(self, update_list): + """Store changes of existing instances so they can be recollected. + + Args: + update_list(List[UpdateData]): Gets list of tuples. Each item + contain changed instance and it's changes. + """ + for created_inst, _changes in update_list: + segment_item = created_inst.transient_data["segment_item"] + marker_data = ayfapi.get_segment_data_marker(segment_item) + + try: + instances_data = marker_data[_CONTENT_ID] + + # Backwards compatible (Deprecated since 24/09/05) + except KeyError: + marker_data[_CONTENT_ID] = {} + instances_data = marker_data[_CONTENT_ID] + + instances_data[self.identifier] = created_inst.data_to_store() + pipeline.imprint( + segment_item, + data= { + _CONTENT_ID: instances_data, + "clip_index": marker_data["clip_index"], + } + ) + + def remove_instances(self, instances): + """Remove instance marker from track item. + + Args: + instance(List[CreatedInstance]): Instance objects which should be + removed. + """ + for instance in instances: + segment_item = instance.transient_data["segment_item"] + marker_data = ayfapi.get_segment_data_marker(segment_item) + + instances_data = marker_data.get(_CONTENT_ID, {}) + instances_data.pop(self.identifier, None) + self._remove_instance_from_context(instance) + + pipeline.imprint( + segment_item, + data= { + _CONTENT_ID: instances_data, + "clip_index": marker_data["clip_index"], + } + ) + + +class FlameShotInstanceCreator(_FlameInstanceCreator): + """Shot product type creator class""" + identifier = "io.ayon.creators.flame.shot" + product_type = "shot" + label = "Editorial Shot" + + def get_instance_attr_defs(self): + instance_attributes = CLIP_ATTR_DEFS + return instance_attributes + + +class _FlameInstanceClipCreatorBase(_FlameInstanceCreator): + """ Base clip product creator. + """ + def register_callbacks(self): + self.create_context.add_value_changed_callback(self._on_value_change) -class CreateShotClip(ayfapi.Creator): + def _on_value_change(self, event): + for item in event["changes"]: + instance = item["instance"] + if ( + instance is None + or instance.creator_identifier != self.identifier + ): + continue + + changes = item["changes"].get("creator_attributes", {}) + if "review" not in changes: + continue + + attr_defs = instance.creator_attributes.attr_defs + review_value = changes["review"] + reviewable_source = next( + attr_def + for attr_def in attr_defs + if attr_def.key == "reviewableSource" + ) + reviewable_source.enabled = review_value + + instance.set_create_attr_defs(attr_defs) + + def get_attr_defs_for_instance(self, instance): + + current_sequence = lib.get_current_sequence(lib.CTX.selection) + if current_sequence is not None: + gui_tracks = [ + {"value": tr_name, "label": f"Track: {tr_name}"} + for tr_name in get_video_track_names(current_sequence) + ] + else: + gui_tracks = [] + + instance_attributes = [ + TextDef( + "parentInstance", + label="Linked to", + disabled=True, + ) + ] + + if self.product_type == "plate": + current_review = instance.creator_attributes.get("review", False) + instance_attributes.extend( + [ + BoolDef( + "review", + label="Review", + tooltip="Switch to reviewable instance", + default=False, + ), + EnumDef( + "reviewableSource", + label="Reviewable Source", + tooltip=("Selecting source for reviewable files."), + items=( + [ + { + "value": "clip_media", + "label": "[ Clip's media ]", + }, + ] + + gui_tracks + ), + disabled=not current_review, + ), + ] + ) + + return instance_attributes + + +class EditorialPlateInstanceCreator(_FlameInstanceClipCreatorBase): + """Plate product type creator class""" + identifier = "io.ayon.creators.flame.plate" + product_type = "plate" + label = "Editorial Plate" + + def create(self, instance_data, _): + """Return a new CreateInstance for new shot from Resolve. + + Args: + instance_data (dict): global data from original instance + + Return: + CreatedInstance: The created instance object for the new shot. + """ + return super().create(instance_data, None) + + +class EditorialAudioInstanceCreator(_FlameInstanceClipCreatorBase): + """Audio product type creator class""" + identifier = "io.ayon.creators.flame.audio" + product_type = "audio" + label = "Editorial Audio" + + +class CreateShotClip(plugin.FlameCreator): """Publishable clip""" + identifier = "io.ayon.creators.flame.clip" label = "Create Publishable Clip" - product_type = "clip" + product_type = "editorial" icon = "film" defaults = ["Main"] - presets = None - - def process(self): - # Creator copy of object attributes that are modified during `process` - presets = deepcopy(self.presets) - gui_inputs = self.get_gui_inputs() - - # get key pairs from presets and match it on ui inputs - for k, v in gui_inputs.items(): - if v["type"] in ("dict", "section"): - # nested dictionary (only one level allowed - # for sections and dict) - for _k, _v in v["value"].items(): - if presets.get(_k) is not None: - gui_inputs[k][ - "value"][_k]["value"] = presets[_k] - - if presets.get(k) is not None: - gui_inputs[k]["value"] = presets[k] - - # open widget for plugins inputs - results_back = self.create_widget( - "AYON publish attributes creator", - "Define sequential rename and fill hierarchy data.", - gui_inputs - ) + detailed_description = """ +Publishing clips/plate, audio for new shots to project +or updating already created from Flame. Publishing will create +OTIO file. +""" + + create_allow_thumbnail = False + + shot_instances = {} + + def get_pre_create_attr_defs(self): + + def header_label(text): + return f"
{text}" + + tokens_help = """\nUsable tokens: + {_clip_}: name of used clip + {_track_}: name of parent track layer + {_sequence_}: name of parent sequence (timeline)""" + + current_sequence = lib.get_current_sequence(lib.CTX.selection) + if current_sequence is not None: + gui_tracks = [ + {"value": tr_name, "label": f"Track: {tr_name}"} + for tr_name in get_video_track_names(current_sequence) + ] + else: + gui_tracks = [] + + # Project settings might be applied to this creator via + # the inherited `Creator.apply_settings` + presets = self.presets + + return [ + + BoolDef("use_selection", + label="Use only selected clip(s).", + tooltip=( + "When enabled it restricts create process " + "to selected clips." + ), + default=True), + + # renameHierarchy + UILabelDef( + label=header_label("Shot Hierarchy And Rename Settings") + ), + TextDef( + "hierarchy", + label="Shot Parent Hierarchy", + tooltip="Parents folder for shot root folder, " + "Template filled with *Hierarchy Data* section", + default=presets.get("hierarchy", "{folder}/{sequence}"), + ), + BoolDef( + "useShotName", + label="Use shot name", + tooltip="Use name form Shot name clip attribute.", + default=presets.get("useShotName", True), + ), + BoolDef( + "clipRename", + label="Rename clips", + tooltip="Renaming selected clips on fly", + default=presets.get("clipRename", False), + ), + TextDef( + "clipName", + label="Clip Name Template", + tooltip="template for creating shot names, used for " + "renaming (use rename: on)", + default=presets.get("clipName", "{sequence}{shot}"), + ), + BoolDef( + "segmentIndex", + label="Segment Index", + tooltip="Take number from segment index", + default=True, + ), + NumberDef( + "countFrom", + label="Count sequence from", + tooltip="Set where the sequence number starts from", + default=presets.get("countFrom", 10), + ), + NumberDef( + "countSteps", + label="Stepping number", + tooltip="What number is adding every new step", + default=presets.get("countSteps", 10), + ), + + # hierarchyData + UILabelDef( + label=header_label("Shot Template Keywords") + ), + TextDef( + "folder", + label="{folder}", + tooltip="Name of folder used for root of generated shots.\n" + f"{tokens_help}", + default=presets.get("folder", "shots"), + ), + TextDef( + "episode", + label="{episode}", + tooltip=f"Name of episode.\n{tokens_help}", + default=presets.get("episode", "ep01"), + ), + TextDef( + "sequence", + label="{sequence}", + tooltip=f"Name of sequence of shots.\n{tokens_help}", + default=presets.get("sequence", "sq01"), + ), + TextDef( + "track", + label="{track}", + tooltip=f"Name of timeline track.\n{tokens_help}", + default=presets.get("track", "{_track_}"), + ), + TextDef( + "shot", + label="{shot}", + tooltip="Name of shot. '#' is converted to padded number." + f"\n{tokens_help}", + default=presets.get("shot", "sh###"), + ), + + # verticalSync + UILabelDef( + label=header_label("Vertical Synchronization Of Attributes") + ), + BoolDef( + "vSyncOn", + label="Enable Vertical Sync", + tooltip="Switch on if you want clips above " + "each other to share its attributes", + default=presets.get("vSyncOn", True), + ), + EnumDef( + "vSyncTrack", + label="Hero track", + tooltip="Select driving track name which should " + "be mastering all others", + items=gui_tracks or [""], + ), + + # publishSettings + UILabelDef( + label=header_label("Publish Settings") + ), + EnumDef( + "clipVariant", + label="Product Variant", + tooltip="Chose variant which will be then used for " + "product name, if " + "is selected, name of track layer will be used", + items=['', 'main', 'bg', 'fg', 'bg', 'animatic'], + ), + EnumDef( + "productType", + label="Product Type", + tooltip="How the product will be used", + items=['plate', 'take'], + ), + EnumDef( + "reviewableSource", + label="Reviewable Source", + tooltip="Select source for reviewable files.", + items=[ + {"value": None, "label": "< none >"}, + {"value": "clip_media", "label": "[ Clip's media ]"}, + ] + + gui_tracks, + ), + BoolDef( + "export_audio", + label="Include audio", + tooltip="Process subsets with corresponding audio", + default=False, + ), + BoolDef( + "sourceResolution", + label="Source resolution", + tooltip="Is resolution taken from timeline or source?", + default=False, + ), + + # shotAttr + UILabelDef( + label=header_label("Shot Attributes"), + ), + NumberDef( + "workfileFrameStart", + label="Workfiles Start Frame", + tooltip="Set workfile starting frame number", + default=presets.get("workfileFrameStart", 1001), + ), + NumberDef( + "handleStart", + label="Handle start (head)", + tooltip="Handle at start of clip", + default=presets.get("handleStart", 0), + ), + NumberDef( + "handleEnd", + label="Handle end (tail)", + tooltip="Handle at end of clip", + default=presets.get("handleEnd", 0), + ), + BoolDef( + "includeHandles", + label="Include handles", + tooltip="Should the handles be included?", + default=False, + ), + BoolDef( + "retimedHandles", + label="Retimed handles", + tooltip="Should the handles be retimed?", + default=True, + ), + BoolDef( + "retimedFramerange", + label="Retimed framerange", + tooltip="Should the framerange be retimed?", + default=True, + ), + ] + + + def create(self, product_name, instance_data, pre_create_data): + super().create( + product_name, + instance_data, + pre_create_data) if len(self.selected) < 1: return - if not results_back: - print("Operation aborted") - return + self.log.info(self.selected) + self.log.debug(f"Selected: {self.selected}") + + audio_clips = [ + audio_track.selected_segments + for audio_track in self.sequence.audio_tracks + ] + + if not any(audio_clips) and pre_create_data.get("export_audio"): + raise CreatorError( + "You must have audio in your active " + "timeline in order to export audio." + ) - # get ui output for track name for vertical sync - v_sync_track = results_back["vSyncTrack"]["value"] + instance_data.update(pre_create_data) + instance_data["task"] = None + + # sort selected trackItems by + v_sync_track = pre_create_data.get("vSyncTrack", "") # sort selected trackItems by sorted_selected_segments = [] @@ -58,250 +579,316 @@ def process(self): sorted_selected_segments.extend(unsorted_selected_segments) - kwargs = { - "log": self.log, - "ui_inputs": results_back, - "basicProductData": self.data, - "productType": self.data["productType"], + # detect enabled creators for review, plate and audio + shot_creator_id = "io.ayon.creators.flame.shot" + plate_creator_id = "io.ayon.creators.flame.plate" + audio_creator_id = "io.ayon.creators.flame.audio" + all_creators = { + shot_creator_id: True, + plate_creator_id: True, + audio_creator_id: True, } + instances = [] + + for idx, segment in enumerate(sorted_selected_segments): + + clip_index = str(uuid.uuid4()) + segment_instance_data = deepcopy(instance_data) + segment_instance_data["clip_index"] = clip_index - for i, segment in enumerate(sorted_selected_segments): - kwargs["rename_index"] = i # convert track item to timeline media pool item - ayfapi.PublishableClip(segment, **kwargs).convert() + publish_clip = ayfapi.PublishableClip( + segment, + log=self.log, + pre_create_data=pre_create_data, + data=segment_instance_data, + product_type=self.product_type, + rename_index=idx, + ) - def get_gui_inputs(self): - gui_tracks = self._get_video_track_names( - ayfapi.get_current_sequence(ayfapi.CTX.selection) - ) - return deepcopy({ - "renameHierarchy": { - "type": "section", - "label": "Shot Hierarchy And Rename Settings", - "target": "ui", - "order": 0, - "value": { - "hierarchy": { - "value": "{folder}/{sequence}", - "type": "QLineEdit", - "label": "Shot Parent Hierarchy", - "target": "tag", - "toolTip": "Parents folder for shot root folder, Template filled with `Hierarchy Data` section", # noqa - "order": 0}, - "useShotName": { - "value": True, - "type": "QCheckBox", - "label": "Use Shot Name", - "target": "ui", - "toolTip": "Use name form Shot name clip attribute", # noqa - "order": 1}, - "clipRename": { - "value": False, - "type": "QCheckBox", - "label": "Rename clips", - "target": "ui", - "toolTip": "Renaming selected clips on fly", # noqa - "order": 2}, - "clipName": { - "value": "{sequence}{shot}", - "type": "QLineEdit", - "label": "Clip Name Template", - "target": "ui", - "toolTip": "template for creating shot namespaused for renaming (use rename: on)", # noqa - "order": 3}, - "segmentIndex": { - "value": True, - "type": "QCheckBox", - "label": "Segment index", - "target": "ui", - "toolTip": "Take number from segment index", # noqa - "order": 4}, - "countFrom": { - "value": 10, - "type": "QSpinBox", - "label": "Count sequence from", - "target": "ui", - "toolTip": "Set when the sequence number stafrom", # noqa - "order": 5}, - "countSteps": { - "value": 10, - "type": "QSpinBox", - "label": "Stepping number", - "target": "ui", - "toolTip": "What number is adding every new step", # noqa - "order": 6}, - } - }, - "hierarchyData": { - "type": "dict", - "label": "Shot Template Keywords", - "target": "tag", - "order": 1, - "value": { - "folder": { - "value": "shots", - "type": "QLineEdit", - "label": "{folder}", - "target": "tag", - "toolTip": "Name of folder used for root of generated shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 0}, - "episode": { - "value": "ep01", - "type": "QLineEdit", - "label": "{episode}", - "target": "tag", - "toolTip": "Name of episode.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 1}, - "sequence": { - "value": "sq01", - "type": "QLineEdit", - "label": "{sequence}", - "target": "tag", - "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 2}, - "track": { - "value": "{_track_}", - "type": "QLineEdit", - "label": "{track}", - "target": "tag", - "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 3}, - "shot": { - "value": "sh###", - "type": "QLineEdit", - "label": "{shot}", - "target": "tag", - "toolTip": "Name of shot. `#` is converted to paded number. \nAlso could be used with usable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 4} - } - }, - "verticalSync": { - "type": "section", - "label": "Vertical Synchronization Of Attributes", - "target": "ui", - "order": 2, - "value": { - "vSyncOn": { - "value": True, - "type": "QCheckBox", - "label": "Enable Vertical Sync", - "target": "ui", - "toolTip": "Switch on if you want clips above each other to share its attributes", # noqa - "order": 0}, - "vSyncTrack": { - "value": gui_tracks, # noqa - "type": "QComboBox", - "label": "Hero track", - "target": "ui", - "toolTip": "Select driving track name which should be hero for all others", # noqa - "order": 1} - } - }, - "publishSettings": { - "type": "section", - "label": "Publish Settings", - "target": "ui", - "order": 3, - "value": { - "productName": { - "value": ["[ track name ]", "main", "bg", "fg", "bg", - "animatic"], - "type": "QComboBox", - "label": "Product Name", - "target": "ui", - "toolTip": "chose product name pattern, if [ track name ] is selected, name of track layer will be used", # noqa - "order": 0}, - "productType": { - "value": ["plate", "take"], - "type": "QComboBox", - "label": "Product Type", - "target": "ui", "toolTip": "What use of this product is for", # noqa - "order": 1}, - "reviewTrack": { - "value": ["< none >"] + gui_tracks, - "type": "QComboBox", - "label": "Use Review Track", - "target": "ui", - "toolTip": "Generate preview videos on fly, if `< none >` is defined nothing will be generated.", # noqa - "order": 2}, - "audio": { - "value": False, - "type": "QCheckBox", - "label": "Include audio", - "target": "tag", - "toolTip": "Process products with corresponding audio", # noqa - "order": 3}, - "sourceResolution": { - "value": False, - "type": "QCheckBox", - "label": "Source resolution", - "target": "tag", - "toolTip": "Is resolution taken from timeline or source?", # noqa - "order": 4}, + segment = publish_clip.convert() + if segment is None: + # Ignore input clips that do not convert into a track item + # from `PublishableClip.convert` + continue + + segment_instance_data.update(publish_clip.marker_data) + self.log.info( + "Processing track item data: {} (index: {})".format( + segment, idx) + ) + + # Delete any existing instances previously generated for the clip. + prev_tag_data = lib.get_segment_data_marker(segment) + if prev_tag_data: + for creator_id, inst_data in prev_tag_data.get(_CONTENT_ID, {}).items(): + creator = self.create_context.creators[creator_id] + prev_instance = self.create_context.instances_by_id.get( + inst_data["instance_id"] + ) + if prev_instance is not None: + creator.remove_instances([prev_instance]) + + # Create new product(s) instances. + clip_instances = {} + # disable shot creator if heroTrack is not enabled + all_creators[shot_creator_id] = segment_instance_data.get( + "heroTrack", False) + # disable audio creator if audio is not enabled + all_creators[audio_creator_id] = ( + segment_instance_data.get("heroTrack", False) and + pre_create_data.get("export_audio", False) + ) + + enabled_creators = tuple(cre for cre, enabled in all_creators.items() if enabled) + clip_instances = {} + shot_folder_path = segment_instance_data["folderPath"] + shot_instances = self.shot_instances.setdefault( + shot_folder_path, {}) + + for creator_id in enabled_creators: + creator = self.create_context.creators[creator_id] + sub_instance_data = deepcopy(segment_instance_data) + shot_folder_path = sub_instance_data["folderPath"] + + # Shot creation + if creator_id == shot_creator_id: + segment_data = flame_export.get_segment_attributes(segment) + segment_duration = int(segment_data["record_duration"]) + workfileFrameStart = \ + sub_instance_data["workfileFrameStart"] + sub_instance_data.update({ + "variant": "main", + "productType": "shot", + "productName": "shotMain", + "creator_attributes": { + "workfileFrameStart": \ + sub_instance_data["workfileFrameStart"], + "handleStart": sub_instance_data["handleStart"], + "handleEnd": sub_instance_data["handleEnd"], + "frameStart": workfileFrameStart, + "frameEnd": (workfileFrameStart + + segment_duration), + "clipIn": int(segment_data["record_in"]), + "clipOut": int(segment_data["record_out"]), + "clipDuration": segment_duration, + "sourceIn": int(segment_data["source_in"]), + "sourceOut": int(segment_data["source_out"]), + "includeHandles": pre_create_data["includeHandles"], + "retimedHandles": pre_create_data["retimedHandles"], + "retimedFramerange": pre_create_data["retimedFramerange"], + }, + "label": f"{shot_folder_path} shot", + }) + + # Plate, Audio + # insert parent instance data to allow + # metadata recollection as publish time. + else: + parenting_data = shot_instances[shot_creator_id] + sub_instance_data.update({ + "parent_instance_id": parenting_data["instance_id"], + "label": ( + f"{sub_instance_data['folderPath']} " + f"{sub_instance_data['productName']}" + ), + "creator_attributes": { + "parentInstance": parenting_data["label"], + } + }) + # add reviewable source to plate if shot has it + if sub_instance_data.get("reviewableSource"): + sub_instance_data["creator_attributes"].update({ + "reviewableSource": sub_instance_data[ + "reviewableSource"], + "review": True, + }) + + instance = creator.create(sub_instance_data, None) + instance.transient_data["segment_item"] = segment + self._add_instance_to_context(instance) + + instance_data_to_store = instance.data_to_store() + shot_instances[creator_id] = instance_data_to_store + clip_instances[creator_id] = instance_data_to_store + + pipeline.imprint( + segment, + data={ + _CONTENT_ID: clip_instances, + "clip_index": clip_index, } + ) + instances.append(instance) + + self.shot_instances = {} + ayfapi.PublishableClip.restore_all_caches() + + return instances + + def _create_and_add_instance(self, data, creator_id, + segment, instances): + """ + Args: + data (dict): The data to re-recreate the instance from. + creator_id (str): The creator id to use. + segment (obj): The associated segment item. + instances (list): Result instance container. + + Returns: + CreatedInstance: The newly created instance. + """ + creator = self.create_context.creators[creator_id] + instance = creator.create(data, None) + instance.transient_data["segment_item"] = segment + self._add_instance_to_context(instance) + instances.append(instance) + return instance + + def _collect_legacy_instance(self, segment, marker_data): + """ Create some instances from legacy marker data. + + Args: + segment (object): The segment associated to the marker. + marker_data (dict): The marker data. + + Returns: + list. All of the created legacy instances. + """ + instance_data = marker_data + instance_data["task"] = None + + clip_index = str(uuid.uuid4()) + instance_data["clip_index"] = clip_index + clip_instances = {} + + # Create parent shot instance. + sub_instance_data = instance_data.copy() + segment_data = flame_export.get_segment_attributes(segment) + segment_duration = int(segment_data["record_duration"]) + workfileFrameStart = \ + sub_instance_data["workfileFrameStart"] + sub_instance_data.update({ + "creator_attributes": { + "workfileFrameStart": \ + sub_instance_data["workfileFrameStart"], + "handleStart": sub_instance_data["handleStart"], + "handleEnd": sub_instance_data["handleEnd"], + "frameStart": workfileFrameStart, + "frameEnd": (workfileFrameStart + + segment_duration), + "clipIn": int(segment_data["record_in"]), + "clipOut": int(segment_data["record_out"]), + "clipDuration": segment_duration, + "sourceIn": int(segment_data["source_in"]), + "sourceOut": int(segment_data["source_out"]), + "includeHandles": sub_instance_data["includeHandles"], + "retimedHandles": sub_instance_data["retimedHandles"], + "retimedFramerange": sub_instance_data["retimedFramerange"], }, - "frameRangeAttr": { - "type": "section", - "label": "Shot Attributes", - "target": "ui", - "order": 4, - "value": { - "workfileFrameStart": { - "value": 1001, - "type": "QSpinBox", - "label": "Workfiles Start Frame", - "target": "tag", - "toolTip": "Set workfile starting frame number", # noqa - "order": 0 - }, - "handleStart": { - "value": 0, - "type": "QSpinBox", - "label": "Handle Start", - "target": "tag", - "toolTip": "Handle at start of clip", # noqa - "order": 1 - }, - "handleEnd": { - "value": 0, - "type": "QSpinBox", - "label": "Handle End", - "target": "tag", - "toolTip": "Handle at end of clip", # noqa - "order": 2 - }, - "includeHandles": { - "value": False, - "type": "QCheckBox", - "label": "Include handles", - "target": "tag", - "toolTip": "By default handles are excluded", # noqa - "order": 3 - }, - "retimedHandles": { - "value": True, - "type": "QCheckBox", - "label": "Retimed handles", - "target": "tag", - "toolTip": "By default handles are retimed.", # noqa - "order": 4 - }, - "retimedFramerange": { - "value": True, - "type": "QCheckBox", - "label": "Retimed framerange", - "target": "tag", - "toolTip": "By default framerange is retimed.", # noqa - "order": 5 - } + "label": ( + f"{sub_instance_data['folderPath']} shot" + ), + }) + + shot_creator_id = "io.ayon.creators.flame.shot" + creator = self.create_context.creators[shot_creator_id] + instance = creator.create(sub_instance_data, None) + instance.transient_data["segment_item"] = segment + self._add_instance_to_context(instance) + clip_instances[shot_creator_id] = instance.data_to_store() + parenting_data = instance + + # Create plate/audio instance + sub_creators = ["io.ayon.creators.flame.plate"] + if instance_data["audio"]: + sub_creators.append( + "io.ayon.creators.flame.audio" + ) + + for sub_creator_id in sub_creators: + sub_instance_data = deepcopy(instance_data) + creator = self.create_context.creators[sub_creator_id] + sub_instance_data.update({ + "parent_instance_id": parenting_data["instance_id"], + "label": ( + f"{sub_instance_data['folderPath']} " + f"{creator.product_type}" + ), + "creator_attributes": { + "parentInstance": parenting_data["label"], } + }) + + # add reviewable source to plate if shot has it + if sub_instance_data.get("reviewableSource") != "< none >": + sub_instance_data["creator_attributes"].update({ + "reviewableSource": sub_instance_data[ + "reviewTrack"], + "review": True, + }) + + instance = creator.create(sub_instance_data, None) + instance.transient_data["segment_item"] = segment + self._add_instance_to_context(instance) + clip_instances[sub_creator_id] = instance.data_to_store() + + # Adjust clip tag to match new publisher + pipeline.imprint( + segment, + data={ + _CONTENT_ID: clip_instances, + "clip_index": clip_index, } - }) + ) + return clip_instances.values() + + def collect_instances(self): + """Collect all created instances from current timeline.""" + current_sequence = lib.get_current_sequence(lib.CTX.selection) + + for segment in lib.get_sequence_segments(current_sequence): + instances = [] + + # attempt to get AYON tag data + marker_data = lib.get_segment_data_marker(segment) + if not marker_data: + continue + + # Legacy instances handling + if _CONTENT_ID not in marker_data: + instances.extend( + self._collect_legacy_instance(segment, marker_data) + ) + continue + + for creator_id, data in marker_data[_CONTENT_ID].items(): + self._create_and_add_instance( + data, creator_id, segment, instances) + + return instances + + def update_instances(self, update_list): + """Never called, update is handled via _FlameInstanceCreator.""" + pass + + def remove_instances(self, instances): + """Never called, update is handled via _FlameInstanceCreator.""" + pass + + +def get_video_track_names(sequence): + """ Get video track names. + + Args: + sequence (object): The sequence object. - def _get_video_track_names(self, sequence): - track_names = [] - for ver in sequence.versions: - for track in ver.tracks: - track_names.append(track.name.get_value()) + Returns: + list. The track names. + """ + track_names = [] + for ver in sequence.versions: + for track in ver.tracks: + track_names.append(track.name.get_value()) - return track_names + return track_names diff --git a/client/ayon_flame/plugins/create/create_workfile.py b/client/ayon_flame/plugins/create/create_workfile.py index d3cf286..dc80135 100644 --- a/client/ayon_flame/plugins/create/create_workfile.py +++ b/client/ayon_flame/plugins/create/create_workfile.py @@ -1,11 +1,15 @@ # -*- coding: utf-8 -*- """Creator plugin for creating workfiles.""" -import ayon_api +import json +import os + from ayon_core.pipeline import ( AutoCreator, CreatedInstance, ) +import ayon_flame.api as flapi + class CreateWorkfile(AutoCreator): """Workfile auto-creator.""" @@ -14,22 +18,66 @@ class CreateWorkfile(AutoCreator): identifier = "io.ayon.creators.flame.workfile" label = "Workfile" product_type = "workfile" - + icon = "fa5.file" default_variant = "Main" - def collect_instances(self): + @staticmethod + def _get_project_workfile_filepath(): + """ + Args: + project_name (str): The project name. + + Returns: + str. The path to the expected Json workfile. + """ + project_name = flapi.get_current_project().name + return os.path.join( + os.environ["AYON_WORKDIR"], + f"{project_name}.workfile" + ) + + def _dump_instance_data(self, data): + """ Dump instance data into a side-car json file. + + Args: + data (dict): The data to push to the project metadata. + + Returns: + bool. Has the metadata been updated. + """ + out_path = self._get_project_workfile_filepath() + with open(out_path, "w", encoding="utf-8") as out_file: + json.dump(data, out_file) + + def _load_instance_data(self): + """ Returns the data stored in side-car json file if exists. + + Returns: + dict. The workfile metadata data. + """ + in_path = self._get_project_workfile_filepath() + try: + with open(in_path) as in_file: + return json.load(in_file) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + def _create_new_instance(self): + """Create a new workfile instance. + + Returns: + dict. The data of the instance to be created. + """ variant = self.default_variant project_name = self.create_context.get_current_project_name() folder_path = self.create_context.get_current_folder_path() task_name = self.create_context.get_current_task_name() host_name = self.create_context.host_name - folder_entity = ayon_api.get_folder_by_path( - project_name, folder_path) - task_entity = ayon_api.get_task_by_name( - project_name, folder_entity["id"], task_name - ) + folder_entity = self.create_context.get_current_folder_entity() + task_entity = self.create_context.get_current_task_entity() + product_name = self.get_product_name( project_name, folder_entity, @@ -56,15 +104,35 @@ def collect_instances(self): current_instance = CreatedInstance( self.product_type, product_name, data, self) self._add_instance_to_context(current_instance) + return current_instance def create(self, options=None): - # no need to create if it is created - # in `collect_instances` - pass + """Auto-create an instance by default.""" + instance_data = self._load_instance_data() + if instance_data: + return + + self.log.info("Auto-creating workfile instance...") + self._create_new_instance() + + def collect_instances(self): + """Collect from timeline marker or create a new one.""" + data = self._load_instance_data() + if not data: + return + + instance = CreatedInstance( + self.product_type, data["productName"], data, self + ) + self._add_instance_to_context(instance) def update_instances(self, update_list): - # TODO: Implement - # This needs to be implemented to allow persisting any instance - # data on resets. We'll need to decide where to store workfile - # instance data reliably. Likely metadata on the *current project*? - pass + """Store changes in project metadata so they can be recollected. + + Args: + update_list(List[UpdateData]): Gets list of tuples. Each item + contain changed instance and its changes. + """ + for created_inst, _ in update_list: + data = created_inst.data_to_store() + self._dump_instance_data(data) diff --git a/client/ayon_flame/plugins/publish/collect_audio.py b/client/ayon_flame/plugins/publish/collect_audio.py new file mode 100644 index 0000000..bc5344d --- /dev/null +++ b/client/ayon_flame/plugins/publish/collect_audio.py @@ -0,0 +1,48 @@ +import pyblish.api + +from ayon_flame.otio import utils + + +class CollectAudio(pyblish.api.InstancePlugin): + """Collect new audio.""" + + order = pyblish.api.CollectorOrder - 0.48 + label = "Collect Audio" + hosts = ["flame"] + families = ["audio"] + + def process(self, instance): + """ + Args: + instance (pyblish.Instance): The shot instance to update. + """ + # Retrieve instance data from parent instance shot instance. + parent_instance_id = instance.data["parent_instance_id"] + edit_shared_data = instance.context.data["editorialSharedData"] + instance.data.update( + edit_shared_data[parent_instance_id] + ) + + # Adjust instance data from parent otio timeline. + otio_timeline = instance.context.data["otioTimeline"] + otio_clip, marker = utils.get_marker_from_clip_index( + otio_timeline, instance.data["clip_index"] + ) + if not otio_clip: + raise RuntimeError("Could not retrieve otioClip for shot %r", instance) + + instance.data["otioClip"] = otio_clip + + if instance.data.get("reviewTrack") is not None: + instance.data["reviewAudio"] = True + # Remove review track to avoid creation of reviewable + # for the instance + instance.data.pop("reviewTrack") + + clip_src = instance.data["otioClip"].source_range + clip_src_in = clip_src.start_time.to_frames() + clip_src_out = clip_src_in + clip_src.duration.to_frames() + instance.data.update({ + "clipInH": clip_src_in, + "clipOutH": clip_src_out + }) diff --git a/client/ayon_flame/plugins/publish/collect_plate.py b/client/ayon_flame/plugins/publish/collect_plate.py new file mode 100644 index 0000000..058e894 --- /dev/null +++ b/client/ayon_flame/plugins/publish/collect_plate.py @@ -0,0 +1,63 @@ +import pyblish + +import ayon_flame.api as ayfapi +from ayon_flame.otio import utils + + +class CollectPlate(pyblish.api.InstancePlugin): + """Collect new plates.""" + + order = order = pyblish.api.CollectorOrder - 0.48 + label = "Collect Plate" + hosts = ["flame"] + families = ["plate"] + + def process(self, instance): + """ + Args: + instance (pyblish.Instance): The shot instance to update. + """ + instance.data["families"].append("clip") + + # Adjust instance data from parent otio timeline. + otio_timeline = instance.context.data["otioTimeline"] + otio_clip, marker = utils.get_marker_from_clip_index( + otio_timeline, instance.data["clip_index"] + ) + if not otio_clip: + raise RuntimeError("Could not retrieve otioClip for shot %r", instance) + + instance.data["otioClip"] = otio_clip + + # solve reviewable options + review_switch = instance.data["creator_attributes"].get( + "review") + reviewable_source = instance.data["creator_attributes"].get( + "reviewableSource") + + if review_switch is True: + if reviewable_source == "clip_media": + instance.data["families"].append("review") + instance.data.pop("reviewTrack", None) + else: + instance.data["reviewTrack"] = reviewable_source + + # remove creator-specific review keys from instance data + instance.data.pop("reviewableSource", None) + instance.data.pop("review", None) + + # Retrieve instance data from parent instance shot instance. + parent_instance_id = instance.data["parent_instance_id"] + edit_shared_data = instance.context.data["editorialSharedData"] + + instance.data.update( + edit_shared_data[parent_instance_id] + ) + + segment_item = instance.data["item"] + clip_data = ayfapi.get_segment_attributes(segment_item) + version_data = instance.data.setdefault("versionData", {}) + version_data["colorSpace"] = clip_data["colour_space"] + instance.data["colorspace"] = clip_data["colour_space"] + + instance.data["shotDurationFromSource"] = instance.data.get("retimedFramerange") diff --git a/client/ayon_flame/plugins/publish/collect_shots.py b/client/ayon_flame/plugins/publish/collect_shots.py new file mode 100644 index 0000000..ce9a526 --- /dev/null +++ b/client/ayon_flame/plugins/publish/collect_shots.py @@ -0,0 +1,303 @@ +import pyblish +import re +from pprint import pformat + +import ayon_flame.api as ayfapi +from ayon_flame.otio import flame_export, utils + +from ayon_core.pipeline import PublishError +from ayon_core.pipeline.editorial import ( + get_media_range_with_retimes +) + + +# constatns +NUM_PATTERN = re.compile(r"([0-9\.]+)") +TXT_PATTERN = re.compile(r"([a-zA-Z]+)") + + +class CollectShot(pyblish.api.InstancePlugin): + """Collect new shots.""" + + order = pyblish.api.CollectorOrder - 0.49 + label = "Collect Shots" + hosts = ["flame"] + families = ["shot"] + + SHARED_KEYS = ( + "folderPath", + "fps", + "handleStart", + "handleEnd", + "item", + "resolutionWidth", + "resolutionHeight", + "retimedHandles", + "retimedFramerange", + "path", + "pixelAspect", + "sourceFirstFrame", + "versionData", + "workfileFrameStart", + "xml_overrides", + ) + + # TODO: add to own plugin for Flame + # TODO: toggle for marking task which should be used for product parent + add_tasks = [] + + @classmethod + def _inject_editorial_shared_data(cls, instance): + """ + Args: + instance (obj): The publishing instance. + """ + context = instance.context + instance_id = instance.data["instance_id"] + + # Inject folderPath and other creator_attributes to ensure + # new shots/hierarchy are properly handled. + creator_attributes = instance.data['creator_attributes'] + instance.data.update(creator_attributes) + + # Inject/Distribute instance shot data as editorialSharedData + # to make it available for clip/plate/audio products + # in sub-collectors. + if not context.data.get("editorialSharedData"): + context.data["editorialSharedData"] = {} + + context.data["editorialSharedData"][instance_id] = { + key: value for key, value in instance.data.items() + if key in cls.SHARED_KEYS + } + + def process(self, instance): + """ + Args: + instance (pyblish.Instance): The shot instance to update. + """ + instance.data["integrate"] = False # no representation for shot + + # Adjust instance data from parent otio timeline. + otio_timeline = instance.context.data["otioTimeline"] + otio_clip, marker = utils.get_marker_from_clip_index( + otio_timeline, instance.data["clip_index"] + ) + if not otio_clip: + raise RuntimeError("Could not retrieve otioClip for shot %r", instance) + + # Compute fps from creator attribute. + creator_attrs = instance.data['creator_attributes'] + if creator_attrs["fps"] == "from_selection": + creator_attrs["fps"] = instance.context.data["fps"] + + # Retrieve AyonData marker for associated clip. + instance.data["otioClip"] = otio_clip + + # Compute additional data + segment_item = None + for item in instance.context.data["flameSegments"]: + item_data = ayfapi.get_segment_data_marker(item) or {} + if item_data.get("clip_index") == instance.data["clip_index"]: + segment_item = item + break + + if segment_item is None: + raise PublishError("Could not retrieve source from sequence segments.") + + comment_attributes = self._get_comment_attributes(segment_item) + instance.data.update(comment_attributes) + + clip_data = ayfapi.get_segment_attributes(segment_item) + clip_name = clip_data["segment_name"] + self.log.debug("clip_name: {}".format(clip_name)) + + # get file path + file_path = clip_data["fpath"] + first_frame = ayfapi.get_frame_from_filename(file_path) or 0 + + # get file path + head, tail = self._get_head_tail( + clip_data, + otio_clip, + creator_attrs["handleStart"], + creator_attrs["handleEnd"] + ) + + # Make sure there is not None and negative number + head = abs(head or 0) + tail = abs(tail or 0) + + # solve handles length + creator_attrs["handleStart"] = min( + creator_attrs["handleStart"], head) + creator_attrs["handleEnd"] = min( + creator_attrs["handleEnd"], tail) + + # Adjust info from track_item on timeline + workfile_start = self._set_workfile_start(creator_attrs) + + instance.data.update({ + "item": segment_item, + "path": file_path, + "sourceFirstFrame": int(first_frame), + "workfileFrameStart": workfile_start, + + #TODO + "flameAddTasks": self.add_tasks, + "tasks": { + task["name"]: {"type": task["type"]} + for task in self.add_tasks + }, + }) + + self._get_resolution_to_data(instance.data, instance.context) + self._inject_editorial_shared_data(instance) + self.log.debug("__ inst_data: {}".format(pformat(instance.data))) + + @staticmethod + def _set_workfile_start(data): + include_handles = data.get("includeHandles") + workfile_start = data["workfileFrameStart"] + handle_start = data["handleStart"] + + if include_handles: + workfile_start += handle_start + + return workfile_start + + def _get_comment_attributes(self, segment): + comment = segment.comment.get_value() + + # try to find attributes + attributes = { + "xml_overrides": { + "pixelRatio": 1.00} + } + # search for `:` + for split in self._split_comments(comment): + # make sure we ignore if not `:` in key + if ":" not in split: + continue + + self._get_xml_preset_attrs( + attributes, split) + + # add xml overrides resolution to instance data + xml_overrides = attributes["xml_overrides"] + if xml_overrides.get("width"): + attributes.update({ + "resolutionWidth": xml_overrides["width"], + "resolutionHeight": xml_overrides["height"], + "pixelAspect": xml_overrides["pixelRatio"] + }) + + return attributes + + def _get_xml_preset_attrs(self, attributes, split): + + # split to key and value + key, value = split.split(":") + + for attr_data in self.xml_preset_attrs_from_comments: + a_name = attr_data["name"] + a_type = attr_data["type"] + + # exclude all not related attributes + if a_name.lower() not in key.lower(): + continue + + # get pattern defined by type + pattern = TXT_PATTERN + if a_type in ("number", "float"): + pattern = NUM_PATTERN + + res_goup = pattern.findall(value) + + # raise if nothing is found as it is not correctly defined + if not res_goup: + raise ValueError(( + "Value for `{}` attribute is not " + "set correctly: `{}`").format(a_name, split)) + + if "string" in a_type: + _value = res_goup[0] + if "float" in a_type: + _value = float(res_goup[0]) + if "number" in a_type: + _value = int(res_goup[0]) + + attributes["xml_overrides"][a_name] = _value + + # condition for resolution in key + if "resolution" in key.lower(): + res_goup = NUM_PATTERN.findall(value) + # check if axpect was also defined + # 1920x1080x1.5 + aspect = res_goup[2] if len(res_goup) > 2 else 1 + + width = int(res_goup[0]) + height = int(res_goup[1]) + pixel_ratio = float(aspect) + attributes["xml_overrides"].update({ + "width": width, + "height": height, + "pixelRatio": pixel_ratio + }) + + def _split_comments(self, comment_string): + # first split comment by comma + pattern = "|".join([",", ";"]) + return re.split(pattern, comment_string) + + def _get_resolution_to_data(self, data, context): + assert data.get("otioClip"), "Missing `otioClip` data" + + # solve source resolution option + if data.get("sourceResolution", None): + otio_clip_metadata = data[ + "otioClip"].media_reference.metadata + data.update({ + "resolutionWidth": otio_clip_metadata[ + "ayon.source.width"], + "resolutionHeight": otio_clip_metadata[ + "ayon.source.height"], + "pixelAspect": otio_clip_metadata[ + "ayon.source.pixelAspect"] + }) + else: + otio_tl_metadata = context.data["otioTimeline"].metadata + data.update({ + "resolutionWidth": otio_tl_metadata["ayon.timeline.width"], + "resolutionHeight": otio_tl_metadata[ + "ayon.timeline.height"], + "pixelAspect": otio_tl_metadata[ + "ayon.timeline.pixelAspect"] + }) + + def _get_head_tail(self, clip_data, otio_clip, handle_start, handle_end): + # calculate head and tail with forward compatibility + head = clip_data.get("segment_head") + tail = clip_data.get("segment_tail") + self.log.debug("__ head: `{}`".format(head)) + self.log.debug("__ tail: `{}`".format(tail)) + + # HACK: it is here to serve for versions below 2021.1 + if not any([head, tail]): + retimed_attributes = get_media_range_with_retimes( + otio_clip, handle_start, handle_end) + self.log.debug( + ">> retimed_attributes: {}".format(retimed_attributes)) + + # retimed head and tail + head = int(retimed_attributes["handleStart"]) + tail = int(retimed_attributes["handleEnd"]) + + return head, tail + + def _create_otio_time_range_from_timeline_item_data(self, clip_data): + frame_start = int(clip_data["record_in"]) + frame_duration = int(clip_data["record_duration"]) + + return flame_export.create_otio_time_range( + frame_start, frame_duration, self.fps) diff --git a/client/ayon_flame/plugins/publish/collect_timeline_instances.py b/client/ayon_flame/plugins/publish/collect_timeline_instances.py deleted file mode 100644 index c43531c..0000000 --- a/client/ayon_flame/plugins/publish/collect_timeline_instances.py +++ /dev/null @@ -1,438 +0,0 @@ -import re -from types import NoneType -import pyblish -import ayon_flame.api as ayfapi -from ayon_flame.otio import flame_export -from ayon_core.pipeline import AYON_INSTANCE_ID, AVALON_INSTANCE_ID -from ayon_core.pipeline.editorial import ( - is_overlapping_otio_ranges, - get_media_range_with_retimes -) - -# # developer reload modules -from pprint import pformat - -# constatns -NUM_PATTERN = re.compile(r"([0-9\.]+)") -TXT_PATTERN = re.compile(r"([a-zA-Z]+)") - - -class CollectTimelineInstances(pyblish.api.ContextPlugin): - """Collect all Timeline segment selection.""" - - order = pyblish.api.CollectorOrder - 0.09 - label = "Collect timeline Instances" - hosts = ["flame"] - - settings_category = "flame" - - audio_track_items = [] - - # settings - xml_preset_attrs_from_comments = [] - - # TODO: add to own plugin for Flame - # TODO: toggle for marking task which should be used for product parent - add_tasks = [] - - def process(self, context): - selected_segments = context.data["flameSelectedSegments"] - self.log.debug("__ selected_segments: {}".format(selected_segments)) - - self.otio_timeline = context.data["otioTimeline"] - self.fps = context.data["fps"] - - # process all selected - for segment in selected_segments: - # get AYON tag data - marker_data = ayfapi.get_segment_data_marker(segment) - - self.log.debug("__ marker_data: {}".format( - pformat(marker_data))) - - if not marker_data: - continue - - if marker_data.get("id") not in { - AYON_INSTANCE_ID, AVALON_INSTANCE_ID}: - continue - - self.log.debug("__ segment.name: {}".format( - segment.name - )) - - comment_attributes = self._get_comment_attributes(segment) - - self.log.debug("_ comment_attributes: {}".format( - pformat(comment_attributes))) - - clip_data = ayfapi.get_segment_attributes(segment) - clip_name = clip_data["segment_name"] - self.log.debug("clip_name: {}".format(clip_name)) - - # get otio clip data - otio_data = self._get_otio_clip_instance_data(clip_data) or {} - self.log.debug("__ otio_data: {}".format(pformat(otio_data))) - - # get file path - file_path = clip_data["fpath"] - - first_frame = ayfapi.get_frame_from_filename(file_path) or 0 - - head, tail = self._get_head_tail( - clip_data, - otio_data["otioClip"], - marker_data["handleStart"], - marker_data["handleEnd"] - ) - - # make sure there is not NoneType rather 0 - if isinstance(head, NoneType): - head = 0 - if isinstance(tail, NoneType): - tail = 0 - - # make sure value is absolute - if head != 0: - head = abs(head) - if tail != 0: - tail = abs(tail) - - # solve handles length - marker_data["handleStart"] = min( - marker_data["handleStart"], head) - marker_data["handleEnd"] = min( - marker_data["handleEnd"], tail) - - # Backward compatibility fix of 'entity_type' > 'folder_type' - if "parents" in marker_data: - for parent in marker_data["parents"]: - if "entity_type" in parent: - parent["folder_type"] = parent.pop("entity_type") - - workfile_start = self._set_workfile_start(marker_data) - - with_audio = bool(marker_data.pop("audio")) - - # add marker data to instance data - inst_data = dict(marker_data.items()) - - # add ocio_data to instance data - inst_data.update(otio_data) - - folder_path = marker_data["folderPath"] - folder_name = marker_data["folderName"] - product_name = marker_data["productName"] - - # insert product type into families - product_type = marker_data["productType"] - families = [product_type, "clip"] - # form label - label = folder_name - if folder_name != clip_name: - label += " ({})".format(clip_name) - label += " {} [{}]".format(product_name, ", ".join(families)) - - inst_data.update({ - "name": "{}_{}".format(folder_name, product_name), - "label": label, - "folderPath": folder_path, - "item": segment, - "families": families, - "publish": marker_data["publish"], - "fps": self.fps, - "workfileFrameStart": workfile_start, - "sourceFirstFrame": int(first_frame), - "retimedHandles": marker_data.get("retimedHandles"), - "shotDurationFromSource": ( - not marker_data.get("retimedFramerange")), - "path": file_path, - "flameAddTasks": self.add_tasks, - "tasks": { - task["name"]: {"type": task["type"]} - for task in self.add_tasks}, - "representations": [], - "newHierarchyIntegration": True, - # Backwards compatible (Deprecated since 24/06/06) - "newAssetPublishing": True, - }) - self.log.debug("__ inst_data: {}".format(pformat(inst_data))) - - # add resolution - self._get_resolution_to_data(inst_data, context) - - # add comment attributes if any - inst_data.update(comment_attributes) - - # create instance - instance = context.create_instance(**inst_data) - - # add colorspace data - instance.data.update({ - "versionData": { - "colorspace": clip_data["colour_space"], - } - }) - - # create shot instance for shot attributes create/update - self._create_shot_instance(context, clip_name, **inst_data) - - self.log.info("Creating instance: {}".format(instance)) - self.log.info( - "_ instance.data: {}".format(pformat(instance.data))) - - if not with_audio: - continue - - # add audioReview attribute to plate instance data - # if reviewTrack is on - if marker_data.get("reviewTrack") is not None: - instance.data["reviewAudio"] = True - - @staticmethod - def _set_workfile_start(data): - include_handles = data.get("includeHandles") - workfile_start = data["workfileFrameStart"] - handle_start = data["handleStart"] - - if include_handles: - workfile_start += handle_start - - return workfile_start - - def _get_comment_attributes(self, segment): - comment = segment.comment.get_value() - - # try to find attributes - attributes = { - "xml_overrides": { - "pixelRatio": 1.00} - } - # search for `:` - # INFO: clip based overrides needs to have specific format - # `key: value` separated `,` or `;`. This is for cases where we - # need to override resolution, aspect ratio, etc. per clip. - for split in self._split_comments(comment): - self.log.debug(f"__ split: {split}") - - # make sure we ignore if not `:` in key - # of if there is more than one `:` in key - if split.count(":") != 1: - continue - - self._get_xml_preset_attrs(attributes, split) - - # add xml overrides resolution to instance data - xml_overrides = attributes["xml_overrides"] - if xml_overrides.get("width"): - attributes.update({ - "resolutionWidth": xml_overrides["width"], - "resolutionHeight": xml_overrides["height"], - "pixelAspect": xml_overrides["pixelRatio"] - }) - - return attributes - - def _get_xml_preset_attrs(self, attributes, split): - """Helper function to get xml preset attributes from comments - - Example of comment: - `resolution:1920x1080;pixelRatio:1.5` - - Args: - attributes (dict): attributes dict to update - split (str): string to split - - Returns: - None - """ - - # split to key and value - key, value = split.split(":") - - for attr_data in self.xml_preset_attrs_from_comments: - a_name = attr_data["name"] - a_type = attr_data["type"] - - # exclude all not related attributes - if a_name.lower() not in key.lower(): - continue - - # get pattern defined by type - pattern = TXT_PATTERN - if a_type in ("number", "float"): - pattern = NUM_PATTERN - - res_group = pattern.findall(value) - - # raise if nothing is found as it is not correctly defined - if not res_group: - raise ValueError(( - "Value for `{}` attribute is not " - "set correctly: `{}`").format(a_name, split)) - - if "string" in a_type: - _value = res_group[0] - if "float" in a_type: - _value = float(res_group[0]) - if "number" in a_type: - _value = int(res_group[0]) - - attributes["xml_overrides"][a_name] = _value - - # condition for resolution in key - if "resolution" in key.lower(): - res_group = NUM_PATTERN.findall(value) - # check if aspect was also defined - # 1920x1080x1.5 - aspect = res_group[2] if len(res_group) > 2 else float(1) - - width = int(res_group[0]) - height = int(res_group[1]) - pixel_ratio = float(aspect) - attributes["xml_overrides"].update({ - "width": width, - "height": height, - "pixelRatio": pixel_ratio - }) - - def _split_comments(self, comment_string): - # first split comment by comma - split_comments = [] - if "," in comment_string: - split_comments.extend(comment_string.split(",")) - elif ";" in comment_string: - split_comments.extend(comment_string.split(";")) - else: - split_comments.append(comment_string) - - return split_comments - - def _get_head_tail(self, clip_data, otio_clip, handle_start, handle_end): - # calculate head and tail with forward compatibility - head = clip_data.get("segment_head") - tail = clip_data.get("segment_tail") - self.log.debug("__ head: `{}`".format(head)) - self.log.debug("__ tail: `{}`".format(tail)) - - # HACK: it is here to serve for versions below 2021.1 - if not any([head, tail]): - retimed_attributes = get_media_range_with_retimes( - otio_clip, handle_start, handle_end) - self.log.debug( - ">> retimed_attributes: {}".format(retimed_attributes)) - - # retimed head and tail - head = int(retimed_attributes["handleStart"]) - tail = int(retimed_attributes["handleEnd"]) - - return head, tail - - def _get_resolution_to_data(self, data, context): - assert data.get("otioClip"), "Missing `otioClip` data" - - # solve source resolution option - if data.get("sourceResolution", None): - otio_clip_metadata = data[ - "otioClip"].media_reference.metadata - data.update({ - "resolutionWidth": otio_clip_metadata[ - "ayon.source.width"], - "resolutionHeight": otio_clip_metadata[ - "ayon.source.height"], - "pixelAspect": otio_clip_metadata[ - "ayon.source.pixelAspect"] - }) - else: - otio_tl_metadata = context.data["otioTimeline"].metadata - data.update({ - "resolutionWidth": otio_tl_metadata["ayon.timeline.width"], - "resolutionHeight": otio_tl_metadata[ - "ayon.timeline.height"], - "pixelAspect": otio_tl_metadata[ - "ayon.timeline.pixelAspect"] - }) - - def _create_shot_instance(self, context, clip_name, **data): - master_layer = data.get("heroTrack") - hierarchy_data = data.get("hierarchyData") - - self.log.info(f"Creating Shot instance for: {clip_name}") - if not master_layer: - return - - if not hierarchy_data: - return - - folder_path = data["folderPath"] - folder_name = folder_path.rsplit("/")[-1] - product_name = "shotMain" - - # insert product type into families - product_type = "shot" - - # form label - label = folder_name - if folder_name != clip_name: - label += " ({}) ".format(clip_name) - label += " {}".format(product_name) - label += " [{}]".format(product_type) - - data.update({ - "name": "{}_{}".format(folder_name, product_name), - "label": label, - "productName": product_name, - "folderPath": folder_path, - "productType": product_type, - "family": product_type, - "families": [product_type], - "integrate": False, - }) - - instance = context.create_instance(**data) - self.log.info("Creating Shot instance: {}".format(instance)) - self.log.debug( - "_ shot instance.data: {}".format(pformat(instance.data))) - - def _get_otio_clip_instance_data(self, clip_data): - """ - Return otio objects for timeline, track and clip - - Args: - timeline_item_data (dict): timeline_item_data from list returned by - resolve.get_current_timeline_items() - otio_timeline (otio.schema.Timeline): otio object - - Returns: - dict: otio clip object - - """ - segment = clip_data["PySegment"] - s_track_name = segment.parent.name.get_value() - timeline_range = self._create_otio_time_range_from_timeline_item_data( - clip_data) - - for otio_clip in self.otio_timeline.find_clips(): - track_name = otio_clip.parent().name - parent_range = otio_clip.range_in_parent() - if s_track_name not in track_name: - continue - if otio_clip.name not in segment.name.get_value(): - continue - if is_overlapping_otio_ranges( - parent_range, timeline_range, strict=True): - - # add pypedata marker to otio_clip metadata - for marker in otio_clip.markers: - if ayfapi.MARKER_NAME in marker.name: - otio_clip.metadata.update(marker.metadata) - return {"otioClip": otio_clip} - - return None - - def _create_otio_time_range_from_timeline_item_data(self, clip_data): - frame_start = int(clip_data["record_in"]) - frame_duration = int(clip_data["record_duration"]) - - return flame_export.create_otio_time_range( - frame_start, frame_duration, self.fps) diff --git a/client/ayon_flame/plugins/publish/collect_timeline_otio.py b/client/ayon_flame/plugins/publish/collect_timeline_otio.py index 4307cd8..34454c3 100644 --- a/client/ayon_flame/plugins/publish/collect_timeline_otio.py +++ b/client/ayon_flame/plugins/publish/collect_timeline_otio.py @@ -9,29 +9,28 @@ class CollecTimelineOTIO(pyblish.api.ContextPlugin): """Inject the current working context into publish context""" label = "Collect Timeline OTIO" - order = pyblish.api.CollectorOrder - 0.099 + order = pyblish.api.CollectorOrder - 0.491 def process(self, context): # main project = ayfapi.get_current_project() sequence = ayfapi.get_current_sequence(ayfapi.CTX.selection) - + segments = ayfapi.get_sequence_segments(sequence) # adding otio timeline to context - with ayfapi.maintained_segment_selection(sequence) as selected_seg: - otio_timeline = flame_export.create_otio_timeline(sequence) - - # update context with main project attributes - timeline_data = { - "flameProject": project, - "flameSequence": sequence, - "otioTimeline": otio_timeline, - "currentFile": "Flame/{}/{}".format( - project.name, sequence.name.get_value() - ), - "flameSelectedSegments": selected_seg, - "fps": float(str(sequence.frame_rate)[:-4]) - } - self.log.debug(f">>> Timeline data: {pformat(timeline_data)}") - context.data.update(timeline_data) + otio_timeline = flame_export.create_otio_timeline(sequence) + + # update context with main project attributes + timeline_data = { + "flameProject": project, + "flameSequence": sequence, + "otioTimeline": otio_timeline, + "currentFile": "Flame/{}/{}".format( + project.name, sequence.name.get_value() + ), + "flameSegments": segments, + "fps": float(str(sequence.frame_rate)[:-4]) + } + self.log.debug(f">>> Timeline data: {pformat(timeline_data)}") + context.data.update(timeline_data)