Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor OTIO frame range collection #1060

Open
wants to merge 5 commits into
base: develop
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 164 additions & 53 deletions client/ayon_core/plugins/publish/collect_otio_frame_ranges.py
Original file line number Diff line number Diff line change
@@ -1,93 +1,204 @@
"""Plugins for collecting OTIO frame ranges and related timing information.

This module contains three plugins:
- CollectOtioFrameRanges: Collects basic timeline frame ranges
- CollectOtioSourceRanges: Collects source media frame ranges
- CollectOtioRetimedRanges: Handles 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 pyblish.api

try:
import opentimelineio as otio
except ImportError:
raise RuntimeError("OpenTimelineIO is not installed.")
Copy link
Member

@iLLiCiTiT iLLiCiTiT Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will cause that the plugin won't be loaded at all. So artist even doesn't know it failed, and he's missing "maybe" important logic.

BTW if it would be kept why to raise RuntimeError instead of keeping ImportError without try > except?


from ayon_core.pipeline.editorial import (
get_media_range_with_retimes,
otio_range_to_frame_range,
otio_range_with_handles,
)


def validate_otio_clip(instance, logger):
"""Validate if instance has required OTIO clip data.

Args:
instance: The instance to validate
logger: Logger object to use for debug messages

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 CollectOtioFrameRanges(pyblish.api.InstancePlugin):
"""Getting otio ranges from otio_clip
"""Collect basic timeline frame ranges from OTIO clip.

Adding timeline and source ranges to instance data"""
This plugin extracts and stores basic timeline frame ranges including
handles from the OTIO clip.

Requires:
otioClip (otio.schema.Clip): OTIO clip object
workfileFrameStart (int): Starting frame of work file

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
"""

label = "Collect OTIO Frame 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 frame ranges.

Args:
instance: The instance to process
"""

if not instance.data.get("otioClip"):
self.log.debug("Skipping collect OTIO frame range.")
if not validate_otio_clip(instance, self.log):
return

# get basic variables
otio_clip = instance.data["otioClip"]
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_handles = otio_range_with_handles(otio_tl_range, instance)

# 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)

# 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
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)

data = {
"frameStart": frame_start,
"frameEnd": frame_end,
"clipIn": tl_start,
"clipOut": tl_end - 1,
"clipInH": tl_start_h,
"clipOutH": tl_end_h - 1,
}
instance.data.update(data)
self.log.debug(f"Added frame ranges: {pformat(data)}")


class CollectOtioSourceRanges(pyblish.api.InstancePlugin):
"""Collect source media frame ranges from OTIO clip.

This plugin extracts and stores source media frame ranges including
handles from the OTIO clip.

Requires:
otioClip (otio.schema.Clip): OTIO clip object

Provides:
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 Source OTIO Frame Ranges"
order = pyblish.api.CollectorOrder - 0.07
families = ["shot", "clip"]
hosts = ["hiero", "flame"]
Copy link
Member

@iLLiCiTiT iLLiCiTiT Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we stop using product types for filtering and use different family instead? That would move responsibility of running the plugin to host integration, which makes it easily expandable to other hosts when needed, without need to change ayon-core.


def process(self, instance):
"""Process the instance to collect source frame ranges.

Args:
instance: The instance to process
"""

if not validate_otio_clip(instance, self.log):
return

otio_clip = instance.data["otioClip"]

# Get source ranges
otio_src_range = otio_clip.source_range
otio_available_range = otio_clip.available_range()
otio_src_range_handles = otio_range_with_handles(otio_src_range, instance)

# Get source available start frame
src_starting_from = otio.opentime.to_frames(
otio_available_range.start_time,
otio_available_range.start_time.rate
)

# Convert to frames
src_start, src_end = otio_range_to_frame_range(otio_src_range)
src_start_h, src_end_h = otio_range_to_frame_range(otio_src_range_handles)

data = {
"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 source ranges: {pformat(data)}")


class CollectOtioRetimedRanges(pyblish.api.InstancePlugin):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are plugins CollectOtioFrameRanges, CollectOtioSourceRanges, CollectOtioRetimedRanges, is there reason why it's 3 plugins instead of one? From what I see in the code, 1 plugin could be 1 method...

"""Update frame ranges for retimed clips.

This plugin updates the frame end value for retimed clips.

Requires:
otioClip (otio.schema.Clip): OTIO clip object
workfileFrameStart (int): Starting frame of work file
shotDurationFromSource (Optional[int]): Duration from source if retimed

Provides:
frameEnd (int): Updated end frame for retimed clips
"""

label = "Collect Retimed OTIO Frame Ranges"
order = pyblish.api.CollectorOrder - 0.06
families = ["shot", "clip"]
hosts = ["hiero", "flame"]

def process(self, instance):
"""Process the instance to handle retimed clips.

Args:
instance: The instance to process
"""
if not validate_otio_clip(instance, self.log):
return

workfile_source_duration = instance.data.get("shotDurationFromSource")
if not workfile_source_duration:
self.log.debug("No source duration found, skipping retime handling.")
return

otio_clip = instance.data["otioClip"]
frame_start = instance.data["frameStart"]

# Handle retimed clip frame range
retimed_attributes = get_media_range_with_retimes(otio_clip, 0, 0)
self.log.debug(f"Retimed attributes: {retimed_attributes}")

media_in = int(retimed_attributes["mediaIn"])
media_out = int(retimed_attributes["mediaOut"])
frame_end = frame_start + (media_out - media_in) + 1

instance.data["frameEnd"] = frame_end
self.log.debug(f"Updated frameEnd for retimed clip: {frame_end}")
Loading