Merge pull request #1060 from ynput/feature/AY-7125_advanced-editorial-publish-to-ayon-38

Refactor OTIO frame range collection
This commit is contained in:
Jakub Ježek 2025-02-25 09:35:07 +01:00 committed by GitHub
commit 1f1b244cf5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 2348 additions and 60 deletions

View file

@ -1,78 +1,120 @@
"""Plugin for collecting OTIO frame ranges and related timing information.
This module contains a unified plugin that handles:
- Basic timeline frame ranges
- Source media frame ranges
- Retimed clip frame ranges
"""
Requires:
otioTimeline -> context data attribute
review -> instance data attribute
masterLayer -> instance data attribute
otioClipRange -> instance data attribute
"""
from pprint import pformat
import opentimelineio as otio
import pyblish.api
from ayon_core.pipeline.editorial import (
get_media_range_with_retimes,
otio_range_to_frame_range,
otio_range_with_handles,
)
class CollectOtioFrameRanges(pyblish.api.InstancePlugin):
"""Getting otio ranges from otio_clip
def validate_otio_clip(instance, logger):
"""Validate if instance has required OTIO clip data.
Adding timeline and source ranges to instance data"""
Args:
instance: The instance to validate
logger: Logger object to use for debug messages
label = "Collect OTIO Frame Ranges"
Returns:
bool: True if valid, False otherwise
"""
if not instance.data.get("otioClip"):
logger.debug("Skipping collect OTIO range - no clip found.")
return False
return True
class CollectOtioRanges(pyblish.api.InstancePlugin):
"""Collect all OTIO-related frame ranges and timing information.
This plugin handles collection of:
- Basic timeline frame ranges with handles
- Source media frame ranges with handles
- Retimed clip frame ranges
Requires:
otioClip (otio.schema.Clip): OTIO clip object
workfileFrameStart (int): Starting frame of work file
Optional:
shotDurationFromSource (int): Duration from source if retimed
Provides:
frameStart (int): Start frame in timeline
frameEnd (int): End frame in timeline
clipIn (int): Clip in point
clipOut (int): Clip out point
clipInH (int): Clip in point with handles
clipOutH (int): Clip out point with handles
sourceStart (int): Source media start frame
sourceEnd (int): Source media end frame
sourceStartH (int): Source media start frame with handles
sourceEndH (int): Source media end frame with handles
"""
label = "Collect OTIO Ranges"
order = pyblish.api.CollectorOrder - 0.08
families = ["shot", "clip"]
hosts = ["resolve", "hiero", "flame", "traypublisher"]
def process(self, instance):
# Not all hosts can import these modules.
import opentimelineio as otio
from ayon_core.pipeline.editorial import (
get_media_range_with_retimes,
otio_range_to_frame_range,
otio_range_with_handles
)
"""Process the instance to collect all frame ranges.
if not instance.data.get("otioClip"):
self.log.debug("Skipping collect OTIO frame range.")
Args:
instance: The instance to process
"""
if not validate_otio_clip(instance, self.log):
return
# get basic variables
otio_clip = instance.data["otioClip"]
# Collect timeline ranges if workfile start frame is available
if "workfileFrameStart" in instance.data:
self._collect_timeline_ranges(instance, otio_clip)
# Traypublisher Simple or Advanced editorial publishing is
# working with otio clips which are having no available range
# because they are not having any media references.
try:
otio_clip.available_range()
has_available_range = True
except otio._otio.CannotComputeAvailableRangeError:
self.log.info("Clip has no available range")
has_available_range = False
# Collect source ranges if clip has available range
if has_available_range:
self._collect_source_ranges(instance, otio_clip)
# Handle retimed ranges if source duration is available
if "shotDurationFromSource" in instance.data:
self._collect_retimed_ranges(instance, otio_clip)
def _collect_timeline_ranges(self, instance, otio_clip):
"""Collect basic timeline frame ranges."""
workfile_start = instance.data["workfileFrameStart"]
workfile_source_duration = instance.data.get("shotDurationFromSource")
# get ranges
# Get timeline ranges
otio_tl_range = otio_clip.range_in_parent()
otio_src_range = otio_clip.source_range
otio_avalable_range = otio_clip.available_range()
otio_tl_range_handles = otio_range_with_handles(
otio_tl_range, instance)
otio_src_range_handles = otio_range_with_handles(
otio_src_range, instance)
otio_tl_range,
instance
)
# get source avalable start frame
src_starting_from = otio.opentime.to_frames(
otio_avalable_range.start_time,
otio_avalable_range.start_time.rate)
# Convert to frames
tl_start, tl_end = otio_range_to_frame_range(otio_tl_range)
tl_start_h, tl_end_h = otio_range_to_frame_range(otio_tl_range_handles)
# convert to frames
range_convert = otio_range_to_frame_range
tl_start, tl_end = range_convert(otio_tl_range)
tl_start_h, tl_end_h = range_convert(otio_tl_range_handles)
src_start, src_end = range_convert(otio_src_range)
src_start_h, src_end_h = range_convert(otio_src_range_handles)
frame_start = workfile_start
frame_end = frame_start + otio.opentime.to_frames(
otio_tl_range.duration, otio_tl_range.duration.rate) - 1
# in case of retimed clip and frame range should not be retimed
if workfile_source_duration:
# get available range trimmed with processed retimes
retimed_attributes = get_media_range_with_retimes(
otio_clip, 0, 0)
self.log.debug(
">> retimed_attributes: {}".format(retimed_attributes))
media_in = int(retimed_attributes["mediaIn"])
media_out = int(retimed_attributes["mediaOut"])
frame_end = frame_start + (media_out - media_in) + 1
self.log.debug(frame_end)
frame_end = frame_start + otio_tl_range.duration.to_frames() - 1
data = {
"frameStart": frame_start,
@ -81,13 +123,77 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin):
"clipOut": tl_end - 1,
"clipInH": tl_start_h,
"clipOutH": tl_end_h - 1,
"sourceStart": src_starting_from + src_start,
"sourceEnd": src_starting_from + src_end - 1,
"sourceStartH": src_starting_from + src_start_h,
"sourceEndH": src_starting_from + src_end_h - 1,
}
instance.data.update(data)
self.log.debug(
"_ data: {}".format(pformat(data)))
self.log.debug(
"_ instance.data: {}".format(pformat(instance.data)))
self.log.debug(f"Added frame ranges: {pformat(data)}")
def _collect_source_ranges(self, instance, otio_clip):
"""Collect source media frame ranges."""
# Get source ranges
otio_src_range = otio_clip.source_range
otio_available_range = otio_clip.available_range()
# Backward-compatibility for Hiero OTIO exporter.
# NTSC compatibility might introduce floating rates, when these are
# not exactly the same (23.976 vs 23.976024627685547)
# this will cause precision issue in computation.
# Currently round to 2 decimals for comparison,
# but this should always rescale after that.
rounded_av_rate = round(otio_available_range.start_time.rate, 2)
rounded_src_rate = round(otio_src_range.start_time.rate, 2)
if rounded_av_rate != rounded_src_rate:
conformed_src_in = otio_src_range.start_time.rescaled_to(
otio_available_range.start_time.rate
)
conformed_src_duration = otio_src_range.duration.rescaled_to(
otio_available_range.duration.rate
)
conformed_source_range = otio.opentime.TimeRange(
start_time=conformed_src_in,
duration=conformed_src_duration
)
else:
conformed_source_range = otio_src_range
source_start = conformed_source_range.start_time
source_end = source_start + conformed_source_range.duration
handle_start = otio.opentime.RationalTime(
instance.data.get("handleStart", 0),
source_start.rate
)
handle_end = otio.opentime.RationalTime(
instance.data.get("handleEnd", 0),
source_start.rate
)
source_start_h = source_start - handle_start
source_end_h = source_end + handle_end
data = {
"sourceStart": source_start.to_frames(),
"sourceEnd": source_end.to_frames() - 1,
"sourceStartH": source_start_h.to_frames(),
"sourceEndH": source_end_h.to_frames() - 1,
}
instance.data.update(data)
self.log.debug(f"Added source ranges: {pformat(data)}")
def _collect_retimed_ranges(self, instance, otio_clip):
"""Handle retimed clip frame ranges."""
retimed_attributes = get_media_range_with_retimes(otio_clip, 0, 0)
self.log.debug(f"Retimed attributes: {retimed_attributes}")
frame_start = instance.data["frameStart"]
media_in = int(retimed_attributes["mediaIn"])
media_out = int(retimed_attributes["mediaOut"])
frame_end = frame_start + (media_out - media_in)
data = {
"frameStart": frame_start,
"frameEnd": frame_end,
"sourceStart": media_in,
"sourceEnd": media_out,
"sourceStartH": media_in - int(retimed_attributes["handleStart"]),
"sourceEndH": media_out + int(retimed_attributes["handleEnd"]),
}
instance.data.update(data)
self.log.debug(f"Updated retimed values: {data}")

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,128 @@
import os
import opentimelineio as otio
from ayon_core.plugins.publish import collect_otio_frame_ranges
_RESOURCE_DIR = os.path.join(
os.path.dirname(__file__),
"resources",
"timeline"
)
class MockInstance():
""" Mock pyblish instance for testing purpose.
"""
def __init__(self, data: dict):
self.data = data
self.context = self
def _check_expected_frame_range_values(
clip_name: str,
expected_data: dict,
handle_start: int = 10,
handle_end: int = 10,
retimed: bool = False,
):
file_path = os.path.join(_RESOURCE_DIR, "timeline.json")
otio_timeline = otio.schema.Timeline.from_json_file(file_path)
for otio_clip in otio_timeline.find_clips():
if otio_clip.name == clip_name:
break
instance_data = {
"otioClip": otio_clip,
"handleStart": handle_start,
"handleEnd": handle_end,
"workfileFrameStart": 1001,
}
if retimed:
instance_data["shotDurationFromSource"] = True
instance = MockInstance(instance_data)
processor = collect_otio_frame_ranges.CollectOtioRanges()
processor.process(instance)
# Assert expected data is subset of edited instance.
assert expected_data.items() <= instance.data.items()
def test_movie_with_timecode():
"""
Movie clip (with embedded timecode)
available_range = 86531-86590 23.976fps
source_range = 86535-86586 23.976fps
"""
expected_data = {
'frameStart': 1001,
'frameEnd': 1052,
'clipIn': 24,
'clipOut': 75,
'clipInH': 14,
'clipOutH': 85,
'sourceStart': 86535,
'sourceStartH': 86525,
'sourceEnd': 86586,
'sourceEndH': 86596,
}
_check_expected_frame_range_values(
"sh010",
expected_data,
)
def test_image_sequence():
"""
EXR image sequence.
available_range = 87399-87482 24fps
source_range = 87311-87336 23.976fps
"""
expected_data = {
'frameStart': 1001,
'frameEnd': 1026,
'clipIn': 76,
'clipOut': 101,
'clipInH': 66,
'clipOutH': 111,
'sourceStart': 87399,
'sourceStartH': 87389,
'sourceEnd': 87424,
'sourceEndH': 87434,
}
_check_expected_frame_range_values(
"img_sequence_exr",
expected_data,
)
def test_media_retimed():
"""
EXR image sequence.
available_range = 345619-345691 23.976fps
source_range = 345623-345687 23.976fps
TimeWarp = frozen frame.
"""
expected_data = {
'frameStart': 1001,
'frameEnd': 1065,
'clipIn': 127,
'clipOut': 191,
'clipInH': 117,
'clipOutH': 201,
'sourceStart': 1001,
'sourceStartH': 1001,
'sourceEnd': 1065,
'sourceEndH': 1065,
}
_check_expected_frame_range_values(
"P01default_twsh010",
expected_data,
retimed=True,
)