diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 311d382ac9..9019b05b21 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -724,7 +724,7 @@ class CreatedInstance: value when set to True. """ - return self._data.get("has_promised_context", False) + return self._transient_data.get("has_promised_context", False) def data_to_store(self): """Collect data that contain json parsable types. diff --git a/client/ayon_core/plugins/actions/show_in_ayon.py b/client/ayon_core/plugins/actions/show_in_ayon.py new file mode 100644 index 0000000000..e30eaa2bc9 --- /dev/null +++ b/client/ayon_core/plugins/actions/show_in_ayon.py @@ -0,0 +1,87 @@ +import os +import urllib.parse +import webbrowser + +from ayon_core.pipeline import LauncherAction +from ayon_core.resources import get_ayon_icon_filepath +import ayon_api + + +def get_ayon_entity_uri( + project_name, + entity_id, + entity_type, +) -> str: + """Resolve AYON Entity URI from representation context. + + Note: + The representation context is the `get_representation_context` dict + containing the `project`, `folder, `representation` and so forth. + It is not the representation entity `context` key. + + Arguments: + project_name (str): The project name. + entity_id (str): The entity UUID. + entity_type (str): The entity type, like "folder" or"task". + + Raises: + RuntimeError: Unable to resolve to a single valid URI. + + Returns: + str: The AYON entity URI. + + """ + response = ayon_api.post( + f"projects/{project_name}/uris", + entityType=entity_type, + ids=[entity_id]) + if response.status_code != 200: + raise RuntimeError( + f"Unable to resolve AYON entity URI for '{project_name}' " + f"{entity_type} id '{entity_id}': {response.text}" + ) + uris = response.data["uris"] + if len(uris) != 1: + raise RuntimeError( + f"Unable to resolve AYON entity URI for '{project_name}' " + f"{entity_type} id '{entity_id}' to single URI. " + f"Received data: {response.data}" + ) + return uris[0]["uri"] + + +class ShowInAYON(LauncherAction): + """Open AYON browser page to the current context.""" + name = "showinayon" + label = "Show in AYON" + icon = get_ayon_icon_filepath() + order = 999 + + def process(self, selection, **kwargs): + url = os.environ["AYON_SERVER_URL"] + if selection.is_project_selected: + project_name = selection.project_name + url += f"/projects/{project_name}/browser" + + # Specify entity URI if task or folder is select + entity = None + entity_type = None + if selection.is_task_selected: + entity = selection.get_task_entity() + entity_type = "task" + elif selection.is_folder_selected: + entity = selection.get_folder_entity() + entity_type = "folder" + + if entity and entity_type: + uri = get_ayon_entity_uri( + project_name, + entity_id=entity["id"], + entity_type=entity_type + ) + uri_encoded = urllib.parse.quote_plus(uri) + url += f"?uri={uri_encoded}" + + # Open URL in webbrowser + self.log.info(f"Opening URL: {url}") + webbrowser.open_new_tab(url) diff --git a/client/ayon_core/plugins/load/export_otio.py b/client/ayon_core/plugins/load/export_otio.py new file mode 100644 index 0000000000..e7a844aed3 --- /dev/null +++ b/client/ayon_core/plugins/load/export_otio.py @@ -0,0 +1,591 @@ +import logging +import os +from pathlib import Path +from collections import defaultdict + +from qtpy import QtWidgets, QtCore, QtGui +from ayon_api import get_representations + +from ayon_core.pipeline import load, Anatomy +from ayon_core import resources, style +from ayon_core.lib.transcoding import ( + IMAGE_EXTENSIONS, + get_oiio_info_for_input, +) +from ayon_core.lib import ( + get_ffprobe_data, + is_oiio_supported, +) +from ayon_core.pipeline.load import get_representation_path_with_anatomy +from ayon_core.tools.utils import show_message_dialog + +OTIO = None +FRAME_SPLITTER = "__frame_splitter__" + +def _import_otio(): + global OTIO + if OTIO is None: + import opentimelineio + OTIO = opentimelineio + + +class ExportOTIO(load.ProductLoaderPlugin): + """Export selected versions to OpenTimelineIO.""" + + is_multiple_contexts_compatible = True + sequence_splitter = "__sequence_splitter__" + + representations = {"*"} + product_types = {"*"} + tool_names = ["library_loader"] + + label = "Export OTIO" + order = 35 + icon = "save" + color = "#d8d8d8" + + def load(self, contexts, name=None, namespace=None, options=None): + _import_otio() + try: + dialog = ExportOTIOOptionsDialog(contexts, self.log) + dialog.exec_() + except Exception: + self.log.error("Failed to export OTIO.", exc_info=True) + + +class ExportOTIOOptionsDialog(QtWidgets.QDialog): + """Dialog to select template where to deliver selected representations.""" + + def __init__(self, contexts, log=None, parent=None): + # Not all hosts have OpenTimelineIO available. + self.log = log + + super().__init__(parent=parent) + + self.setWindowTitle("AYON - Export OTIO") + icon = QtGui.QIcon(resources.get_ayon_icon_filepath()) + self.setWindowIcon(icon) + + self.setWindowFlags( + QtCore.Qt.WindowStaysOnTopHint + | QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowMinimizeButtonHint + ) + + project_name = contexts[0]["project"]["name"] + versions_by_id = { + context["version"]["id"]: context["version"] + for context in contexts + } + repre_entities = list(get_representations( + project_name, version_ids=set(versions_by_id) + )) + version_by_representation_id = { + repre_entity["id"]: versions_by_id[repre_entity["versionId"]] + for repre_entity in repre_entities + } + version_path_by_id = {} + representations_by_version_id = {} + for context in contexts: + version_id = context["version"]["id"] + if version_id in version_path_by_id: + continue + representations_by_version_id[version_id] = [] + version_path_by_id[version_id] = "/".join([ + context["folder"]["path"], + context["product"]["name"], + context["version"]["name"] + ]) + + for repre_entity in repre_entities: + representations_by_version_id[repre_entity["versionId"]].append( + repre_entity + ) + + all_representation_names = list(sorted({ + repo_entity["name"] + for repo_entity in repre_entities + })) + + input_widget = QtWidgets.QWidget(self) + input_layout = QtWidgets.QGridLayout(input_widget) + input_layout.setContentsMargins(8, 8, 8, 8) + + row = 0 + repres_label = QtWidgets.QLabel("Representations:", input_widget) + input_layout.addWidget(repres_label, row, 0) + repre_name_buttons = [] + for idx, name in enumerate(all_representation_names): + repre_name_btn = QtWidgets.QPushButton(name, input_widget) + input_layout.addWidget( + repre_name_btn, row, idx + 1, + alignment=QtCore.Qt.AlignCenter + ) + repre_name_btn.clicked.connect(self._toggle_all) + repre_name_buttons.append(repre_name_btn) + + row += 1 + + representation_widgets = defaultdict(list) + items = representations_by_version_id.items() + for version_id, representations in items: + version_path = version_path_by_id[version_id] + label_widget = QtWidgets.QLabel(version_path, input_widget) + input_layout.addWidget(label_widget, row, 0) + + repres_by_name = { + repre_entity["name"]: repre_entity + for repre_entity in representations + } + radio_group = QtWidgets.QButtonGroup(input_widget) + for idx, name in enumerate(all_representation_names): + if name in repres_by_name: + widget = QtWidgets.QRadioButton(input_widget) + radio_group.addButton(widget) + representation_widgets[name].append( + { + "widget": widget, + "representation": repres_by_name[name] + } + ) + else: + widget = QtWidgets.QLabel("x", input_widget) + + input_layout.addWidget( + widget, row, idx + 1, 1, 1, + alignment=QtCore.Qt.AlignCenter + ) + + row += 1 + + export_widget = QtWidgets.QWidget(self) + + options_widget = QtWidgets.QWidget(export_widget) + + uri_label = QtWidgets.QLabel("URI paths:", options_widget) + uri_path_format = QtWidgets.QCheckBox(options_widget) + uri_path_format.setToolTip( + "Use URI paths (file:///) instead of absolute paths. " + "This is useful when the OTIO file will be used on Foundry Hiero." + ) + + button_output_path = QtWidgets.QPushButton( + "Output Path:", options_widget + ) + button_output_path.setToolTip( + "Click to select the output path for the OTIO file." + ) + + line_edit_output_path = QtWidgets.QLineEdit( + (Path.home() / f"{project_name}.otio").as_posix(), + options_widget + ) + + options_layout = QtWidgets.QHBoxLayout(options_widget) + options_layout.setContentsMargins(0, 0, 0, 0) + options_layout.addWidget(uri_label) + options_layout.addWidget(uri_path_format) + options_layout.addWidget(button_output_path) + options_layout.addWidget(line_edit_output_path) + + button_export = QtWidgets.QPushButton("Export", export_widget) + + export_layout = QtWidgets.QVBoxLayout(export_widget) + export_layout.setContentsMargins(0, 0, 0, 0) + export_layout.addWidget(options_widget, 0) + export_layout.addWidget(button_export, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(8, 8, 8, 8) + main_layout.addWidget(input_widget, 0) + main_layout.addStretch(1) + # TODO add line spacer? + main_layout.addSpacing(30) + main_layout.addWidget(export_widget, 0) + + button_export.clicked.connect(self._on_export_click) + button_output_path.clicked.connect(self._set_output_path) + + self._project_name = project_name + self._version_path_by_id = version_path_by_id + self._version_by_representation_id = version_by_representation_id + self._representation_widgets = representation_widgets + self._repre_name_buttons = repre_name_buttons + + self._uri_path_format = uri_path_format + self._button_output_path = button_output_path + self._line_edit_output_path = line_edit_output_path + self._button_export = button_export + + self._first_show = True + + def showEvent(self, event): + super().showEvent(event) + if self._first_show: + self._first_show = False + self.setStyleSheet(style.load_stylesheet()) + + def _toggle_all(self): + representation_name = self.sender().text() + for item in self._representation_widgets[representation_name]: + item["widget"].setChecked(True) + + def _set_output_path(self): + file_path, _ = QtWidgets.QFileDialog.getSaveFileName( + None, "Save OTIO file.", "", "OTIO Files (*.otio)" + ) + if file_path: + self._line_edit_output_path.setText(file_path) + + def _on_export_click(self): + output_path = self._line_edit_output_path.text() + # Validate output path is not empty. + if not output_path: + show_message_dialog( + "Missing output path", + ( + "Output path is empty. Please enter a path to export the " + "OTIO file to." + ), + level="critical", + parent=self + ) + return + + # Validate output path ends with .otio. + if not output_path.endswith(".otio"): + show_message_dialog( + "Wrong extension.", + ( + "Output path needs to end with \".otio\"." + ), + level="critical", + parent=self + ) + return + + representations = [] + for name, items in self._representation_widgets.items(): + for item in items: + if item["widget"].isChecked(): + representations.append(item["representation"]) + + anatomy = Anatomy(self._project_name) + clips_data = {} + for representation in representations: + version = self._version_by_representation_id[ + representation["id"] + ] + name = ( + f'{self._version_path_by_id[version["id"]]}' + f'/{representation["name"]}' + ).replace("/", "_") + + clips_data[name] = { + "representation": representation, + "anatomy": anatomy, + "frames": ( + version["attrib"]["frameEnd"] + - version["attrib"]["frameStart"] + ), + "framerate": version["attrib"]["fps"], + } + + self.export_otio(clips_data, output_path) + + # Feedback about success. + show_message_dialog( + "Success!", + "Export was successful.", + level="info", + parent=self + ) + + self.close() + + def create_clip(self, name, clip_data, timeline_framerate): + representation = clip_data["representation"] + anatomy = clip_data["anatomy"] + frames = clip_data["frames"] + framerate = clip_data["framerate"] + + # Get path to representation with correct frame number + repre_path = get_representation_path_with_anatomy( + representation, anatomy) + + media_start_frame = clip_start_frame = 0 + media_framerate = framerate + if file_metadata := get_image_info_metadata( + repre_path, ["timecode", "duration", "framerate"], self.log + ): + # get media framerate and convert to float with 3 decimal places + media_framerate = file_metadata["framerate"] + media_framerate = float(f"{media_framerate:.4f}") + framerate = float(f"{timeline_framerate:.4f}") + + media_start_frame = self.get_timecode_start_frame( + media_framerate, file_metadata + ) + clip_start_frame = self.get_timecode_start_frame( + timeline_framerate, file_metadata + ) + + if "duration" in file_metadata: + frames = int(float(file_metadata["duration"]) * framerate) + + repre_path = Path(repre_path) + + first_frame = representation["context"].get("frame") + if first_frame is None: + media_range = OTIO.opentime.TimeRange( + start_time=OTIO.opentime.RationalTime( + media_start_frame, media_framerate + ), + duration=OTIO.opentime.RationalTime( + frames, media_framerate), + ) + clip_range = OTIO.opentime.TimeRange( + start_time=OTIO.opentime.RationalTime( + clip_start_frame, timeline_framerate + ), + duration=OTIO.opentime.RationalTime( + frames, timeline_framerate), + ) + + # Use 'repre_path' as single file + media_reference = OTIO.schema.ExternalReference( + available_range=media_range, + target_url=self.convert_to_uri_or_posix(repre_path), + ) + else: + # This is sequence + repre_files = [ + file["path"].format(root=anatomy.roots) + for file in representation["files"] + ] + # Change frame in representation context to get path with frame + # splitter. + representation["context"]["frame"] = FRAME_SPLITTER + frame_repre_path = get_representation_path_with_anatomy( + representation, anatomy + ) + frame_repre_path = Path(frame_repre_path) + repre_dir, repre_filename = ( + frame_repre_path.parent, frame_repre_path.name) + # Get sequence prefix and suffix + file_prefix, file_suffix = repre_filename.split(FRAME_SPLITTER) + # Get frame number from path as string to get frame padding + frame_str = str(repre_path)[len(file_prefix):][:len(file_suffix)] + frame_padding = len(frame_str) + + media_range = OTIO.opentime.TimeRange( + start_time=OTIO.opentime.RationalTime( + media_start_frame, media_framerate + ), + duration=OTIO.opentime.RationalTime( + len(repre_files), media_framerate + ), + ) + clip_range = OTIO.opentime.TimeRange( + start_time=OTIO.opentime.RationalTime( + clip_start_frame, timeline_framerate + ), + duration=OTIO.opentime.RationalTime( + len(repre_files), timeline_framerate + ), + ) + + media_reference = OTIO.schema.ImageSequenceReference( + available_range=media_range, + start_frame=int(first_frame), + frame_step=1, + rate=framerate, + target_url_base=f"{self.convert_to_uri_or_posix(repre_dir)}/", + name_prefix=file_prefix, + name_suffix=file_suffix, + frame_zero_padding=frame_padding, + ) + + return OTIO.schema.Clip( + name=name, media_reference=media_reference, source_range=clip_range + ) + + def convert_to_uri_or_posix(self, path: Path) -> str: + """Convert path to URI or Posix path. + + Args: + path (Path): Path to convert. + + Returns: + str: Path as URI or Posix path. + """ + if self._uri_path_format.isChecked(): + return path.as_uri() + + return path.as_posix() + + def get_timecode_start_frame(self, framerate, file_metadata): + # use otio to convert timecode into frame number + timecode_start_frame = OTIO.opentime.from_timecode( + file_metadata["timecode"], framerate) + return timecode_start_frame.to_frames() + + def export_otio(self, clips_data, output_path): + # first find the highest framerate and set it as default framerate + # for the timeline + timeline_framerate = 0 + for clip_data in clips_data.values(): + framerate = clip_data["framerate"] + if framerate > timeline_framerate: + timeline_framerate = framerate + + # reduce decimal places to 3 - otio does not like more + timeline_framerate = float(f"{timeline_framerate:.4f}") + + # create clips from the representations + clips = [ + self.create_clip(name, clip_data, timeline_framerate) + for name, clip_data in clips_data.items() + ] + timeline = OTIO.schema.timeline_from_clips(clips) + + # set the timeline framerate to the highest framerate + timeline.global_start_time = OTIO.opentime.RationalTime( + 0, timeline_framerate) + + OTIO.adapters.write_to_file(timeline, output_path) + + +def get_image_info_metadata( + path_to_file, + keys=None, + logger=None, +): + """Get flattened metadata from image file + + With combined approach via FFMPEG and OIIOTool. + + At first it will try to detect if the image input is supported by + OpenImageIO. If it is then it gets the metadata from the image using + OpenImageIO. If it is not supported by OpenImageIO then it will try to + get the metadata using FFprobe. + + Args: + path_to_file (str): Path to image file. + keys (list[str]): List of keys that should be returned. If None then + all keys are returned. Keys are expected all lowercase. + Additional keys are: + - "framerate" - will be created from "r_frame_rate" or + "framespersecond" and evaluated to float value. + logger (logging.Logger): Logger used for logging. + """ + if logger is None: + logger = logging.getLogger(__name__) + + def _ffprobe_metadata_conversion(metadata): + """Convert ffprobe metadata unified format.""" + output = {} + for key, val in metadata.items(): + if key in ("tags", "disposition"): + output.update(val) + else: + output[key] = val + return output + + def _get_video_metadata_from_ffprobe(ffprobe_stream): + """Extract video metadata from ffprobe stream. + + Args: + ffprobe_stream (dict): Stream data obtained from ffprobe. + + Returns: + dict: Video metadata extracted from the ffprobe stream. + """ + video_stream = None + for stream in ffprobe_stream["streams"]: + if stream["codec_type"] == "video": + video_stream = stream + break + metadata_stream = _ffprobe_metadata_conversion(video_stream) + return metadata_stream + + metadata_stream = None + ext = os.path.splitext(path_to_file)[-1].lower() + if ext not in IMAGE_EXTENSIONS: + logger.info( + ( + 'File extension "{}" is not supported by OpenImageIO.' + " Trying to get metadata using FFprobe." + ).format(ext) + ) + ffprobe_stream = get_ffprobe_data(path_to_file, logger) + if "streams" in ffprobe_stream and len(ffprobe_stream["streams"]) > 0: + metadata_stream = _get_video_metadata_from_ffprobe(ffprobe_stream) + + if not metadata_stream and is_oiio_supported(): + oiio_stream = get_oiio_info_for_input(path_to_file, logger=logger) + if "attribs" in (oiio_stream or {}): + metadata_stream = {} + for key, val in oiio_stream["attribs"].items(): + if "smpte:" in key.lower(): + key = key.replace("smpte:", "") + metadata_stream[key.lower()] = val + for key, val in oiio_stream.items(): + if key == "attribs": + continue + metadata_stream[key] = val + else: + logger.info( + ( + "OpenImageIO is not supported on this system." + " Trying to get metadata using FFprobe." + ) + ) + ffprobe_stream = get_ffprobe_data(path_to_file, logger) + if "streams" in ffprobe_stream and len(ffprobe_stream["streams"]) > 0: + metadata_stream = _get_video_metadata_from_ffprobe(ffprobe_stream) + + if not metadata_stream: + logger.warning("Failed to get metadata from image file.") + return {} + + if keys is None: + return metadata_stream + + # create framerate key from available ffmpeg:r_frame_rate + # or oiiotool:framespersecond and evaluate its string expression + # value into flaot value + if ( + "r_frame_rate" in metadata_stream + or "framespersecond" in metadata_stream + ): + rate_info = metadata_stream.get("r_frame_rate") + if rate_info is None: + rate_info = metadata_stream.get("framespersecond") + + # calculate framerate from string expression + if "/" in str(rate_info): + time, frame = str(rate_info).split("/") + rate_info = float(time) / float(frame) + + try: + metadata_stream["framerate"] = float(str(rate_info)) + except Exception as e: + logger.warning( + "Failed to evaluate '{}' value to framerate. Error: {}".format( + rate_info, e + ) + ) + + # aggregate all required metadata from prepared metadata stream + output = {} + for key in keys: + for k, v in metadata_stream.items(): + if key == k: + output[key] = v + break + if isinstance(v, dict) and key in v: + output[key] = v[key] + break + + return output diff --git a/client/ayon_core/plugins/publish/collect_context_entities.py b/client/ayon_core/plugins/publish/collect_context_entities.py index c8d25bc3e6..4de83f0d53 100644 --- a/client/ayon_core/plugins/publish/collect_context_entities.py +++ b/client/ayon_core/plugins/publish/collect_context_entities.py @@ -53,8 +53,9 @@ class CollectContextEntities(pyblish.api.ContextPlugin): context.data["folderEntity"] = folder_entity context.data["taskEntity"] = task_entity - - folder_attributes = folder_entity["attrib"] + context_attributes = ( + task_entity["attrib"] if task_entity else folder_entity["attrib"] + ) # Task type task_type = None @@ -63,12 +64,12 @@ class CollectContextEntities(pyblish.api.ContextPlugin): context.data["taskType"] = task_type - frame_start = folder_attributes.get("frameStart") + frame_start = context_attributes.get("frameStart") if frame_start is None: frame_start = 1 self.log.warning("Missing frame start. Defaulting to 1.") - frame_end = folder_attributes.get("frameEnd") + frame_end = context_attributes.get("frameEnd") if frame_end is None: frame_end = 2 self.log.warning("Missing frame end. Defaulting to 2.") @@ -76,8 +77,8 @@ class CollectContextEntities(pyblish.api.ContextPlugin): context.data["frameStart"] = frame_start context.data["frameEnd"] = frame_end - handle_start = folder_attributes.get("handleStart") or 0 - handle_end = folder_attributes.get("handleEnd") or 0 + handle_start = context_attributes.get("handleStart") or 0 + handle_end = context_attributes.get("handleEnd") or 0 context.data["handleStart"] = int(handle_start) context.data["handleEnd"] = int(handle_end) @@ -87,7 +88,7 @@ class CollectContextEntities(pyblish.api.ContextPlugin): context.data["frameStartHandle"] = frame_start_h context.data["frameEndHandle"] = frame_end_h - context.data["fps"] = folder_attributes["fps"] + context.data["fps"] = context_attributes["fps"] def _get_folder_entity(self, project_name, folder_path): if not folder_path: diff --git a/client/ayon_core/plugins/publish/extract_burnin.py b/client/ayon_core/plugins/publish/extract_burnin.py index 58a032a030..2007240d3d 100644 --- a/client/ayon_core/plugins/publish/extract_burnin.py +++ b/client/ayon_core/plugins/publish/extract_burnin.py @@ -199,7 +199,7 @@ class ExtractBurnin(publish.Extractor): if not burnins_per_repres: self.log.debug( "Skipped instance. No representations found matching a burnin" - "definition in: %s", burnin_defs + " definition in: %s", burnin_defs ) return @@ -399,7 +399,7 @@ class ExtractBurnin(publish.Extractor): add_repre_files_for_cleanup(instance, new_repre) - # Cleanup temp staging dir after procesisng of output definitions + # Cleanup temp staging dir after processing of output definitions if do_convert: temp_dir = repre["stagingDir"] shutil.rmtree(temp_dir) @@ -420,6 +420,12 @@ class ExtractBurnin(publish.Extractor): self.log.debug("Removed: \"{}\"".format(filepath)) def _get_burnin_options(self): + """Get the burnin options from `ExtractBurnin` settings. + + Returns: + dict[str, Any]: Burnin options. + + """ # Prepare burnin options burnin_options = copy.deepcopy(self.default_options) if self.options: @@ -696,7 +702,7 @@ class ExtractBurnin(publish.Extractor): """Prepare data for representation. Args: - instance (Instance): Currently processed Instance. + instance (pyblish.api.Instance): Currently processed Instance. repre (dict): Currently processed representation. burnin_data (dict): Copy of basic burnin data based on instance data. @@ -752,9 +758,11 @@ class ExtractBurnin(publish.Extractor): Args: profile (dict): Profile from presets matching current context. + instance (pyblish.api.Instance): Publish instance. Returns: - list: Contain all valid output definitions. + list[dict[str, Any]]: Contain all valid output definitions. + """ filtered_burnin_defs = [] @@ -773,12 +781,11 @@ class ExtractBurnin(publish.Extractor): if not self.families_filter_validation( families, families_filters ): - self.log.debug(( - "Skipped burnin definition \"{}\". Family" - " filters ({}) does not match current instance families: {}" - ).format( - filename_suffix, str(families_filters), str(families) - )) + self.log.debug( + f"Skipped burnin definition \"{filename_suffix}\"." + f" Family filters ({families_filters}) does not match" + f" current instance families: {families}" + ) continue # Burnin values diff --git a/client/ayon_core/tests/conftest.py b/client/ayon_core/tests/conftest.py new file mode 100644 index 0000000000..f66af706e1 --- /dev/null +++ b/client/ayon_core/tests/conftest.py @@ -0,0 +1,16 @@ +import pytest +from pathlib import Path + +collect_ignore = ["vendor", "resources"] + +RESOURCES_PATH = 'resources' + + +@pytest.fixture +def resources_path_factory(): + def factory(*args): + dirpath = Path(__file__).parent / RESOURCES_PATH + for arg in args: + dirpath = dirpath / arg + return dirpath + return factory diff --git a/client/ayon_core/tests/plugins/load/test_export_otio.py b/client/ayon_core/tests/plugins/load/test_export_otio.py new file mode 100644 index 0000000000..cdcb15033a --- /dev/null +++ b/client/ayon_core/tests/plugins/load/test_export_otio.py @@ -0,0 +1,52 @@ +import pytest +import logging +from pathlib import Path +from ayon_core.plugins.load.export_otio import get_image_info_metadata + +logger = logging.getLogger('test_transcoding') + + +@pytest.mark.parametrize( + "resources_path_factory, metadata, expected, test_id", + [ + ( + Path(__file__).parent.parent + / "resources" + / "lib" + / "transcoding" + / "a01vfxd_sh010_plateP01_v002.1013.exr", + ["timecode", "framerate"], + {"timecode": "01:00:06:03", "framerate": 23.976023976023978}, + "test_01", + ), + ( + Path(__file__).parent.parent + / "resources" + / "lib" + / "transcoding" + / "a01vfxd_sh010_plateP01_v002.1013.exr", + ["timecode", "width", "height", "duration"], + {"timecode": "01:00:06:03", "width": 1920, "height": 1080}, + "test_02", + ), + ( + Path(__file__).parent.parent + / "resources" + / "lib" + / "transcoding" + / "a01vfxd_sh010_plateP01_v002.mov", + ["width", "height", "duration"], + {"width": 1920, "height": 1080, "duration": "0.041708"}, + "test_03", + ), + ], +) +def test_get_image_info_metadata_happy_path( + resources_path_factory, metadata, expected, test_id +): + path_to_file = resources_path_factory.as_posix() + + returned_data = get_image_info_metadata(path_to_file, metadata, logger) + logger.info(f"Returned data: {returned_data}") + + assert returned_data == expected diff --git a/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.1013.exr b/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.1013.exr new file mode 100644 index 0000000000..9f7bc625bc Binary files /dev/null and b/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.1013.exr differ diff --git a/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.mov b/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.mov new file mode 100644 index 0000000000..7b477114b3 Binary files /dev/null and b/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.mov differ diff --git a/pyproject.toml b/pyproject.toml index 35d0df0964..db98ee4eba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ ayon-python-api = "^1.0" ruff = "^0.3.3" pre-commit = "^3.6.2" codespell = "^2.2.6" +semver = "^3.0.2" [tool.ruff] @@ -113,3 +114,12 @@ quiet-level = 3 [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + + +[tool.pytest.ini_options] +log_cli = true +log_cli_level = "INFO" +addopts = "-ra -q" +testpaths = [ + "client/ayon_core/tests" +]