mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
Merge branch 'develop' into enhancement/simplify_ExtractOIIOTranscode_settings
This commit is contained in:
commit
dddc9dcc6e
10 changed files with 782 additions and 18 deletions
|
|
@ -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.
|
||||
|
|
|
|||
87
client/ayon_core/plugins/actions/show_in_ayon.py
Normal file
87
client/ayon_core/plugins/actions/show_in_ayon.py
Normal file
|
|
@ -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)
|
||||
591
client/ayon_core/plugins/load/export_otio.py
Normal file
591
client/ayon_core/plugins/load/export_otio.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
client/ayon_core/tests/conftest.py
Normal file
16
client/ayon_core/tests/conftest.py
Normal file
|
|
@ -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
|
||||
52
client/ayon_core/tests/plugins/load/test_export_otio.py
Normal file
52
client/ayon_core/tests/plugins/load/test_export_otio.py
Normal file
|
|
@ -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
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue