diff --git a/setup.py b/setup.py index 5827594..bd83d54 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ }, install_requires=[ "OpenTimelineIO>=0.14.1", - "pyaaf2>=1.4.0", + "pyaaf2>=1.7.0", ], extras_require={ "dev": [ diff --git a/src/otio_aaf_adapter/adapters/aaf_adapter/aaf_writer.py b/src/otio_aaf_adapter/adapters/aaf_adapter/aaf_writer.py index 63e6749..350d8c2 100644 --- a/src/otio_aaf_adapter/adapters/aaf_adapter/aaf_writer.py +++ b/src/otio_aaf_adapter/adapters/aaf_adapter/aaf_writer.py @@ -5,8 +5,14 @@ Specifies how to transcribe an OpenTimelineIO file into an AAF file. """ +from . import hooks + +from pathlib import Path +from typing import Tuple +from typing import List import aaf2 +import aaf2.mobs import abc import uuid import opentimelineio as otio @@ -14,7 +20,6 @@ import copy import re - AAF_PARAMETERDEF_PAN = aaf2.auid.AUID("e4962322-2267-11d3-8a4c-0050040ef7d2") AAF_OPERATIONDEF_MONOAUDIOPAN = aaf2.auid.AUID("9d2ea893-0968-11d3-8a38-0050040ef7d2") AAF_PARAMETERDEF_AVIDPARAMETERBYTEORDER = uuid.UUID( @@ -37,11 +42,9 @@ def _is_considered_gap(thing): if isinstance(thing, otio.schema.Gap): return True - if ( - isinstance(thing, otio.schema.Clip) - and isinstance( - thing.media_reference, - otio.schema.GeneratorReference) + if isinstance(thing, otio.schema.Clip) and isinstance( + thing.media_reference, + otio.schema.GeneratorReference ): if thing.media_reference.generator_kind in ("Slug",): return True @@ -70,15 +73,20 @@ class AAFFileTranscriber: otio to aaf. This includes keeping track of unique tapemobs and mastermobs. """ - def __init__(self, input_otio, aaf_file, **kwargs): + def __init__(self, input_otio, aaf_file, embed_essence, create_edgecode, **kwargs): """ AAFFileTranscriber requires an input timeline and an output pyaaf2 file handle. Args: - input_otio: an input OpenTimelineIO timeline - aaf_file: a pyaaf2 file handle to an output file + input_otio(otio.schema.Timeline): an input OpenTimelineIO timeline + aaf_file(aaf2.file.AAFFile): a pyaaf2 file handle to an output file + embed_essence(bool): if `True`, media references will be embedded into AAF + create_edgecode(bool): if `True` each clip will get an EdgeCode slot + assigned that defines the Avid Frame Count Start / End. """ self.aaf_file = aaf_file + self.embed_essence = embed_essence + self.create_edgecode = create_edgecode self.compositionmob = self.aaf_file.create.CompositionMob() self.compositionmob.name = input_otio.name self.compositionmob.usage = "Usage_TopLevel" @@ -111,9 +119,10 @@ def _unique_tapemob(self, otio_clip): # to use drop frame with a nominal integer fps. edit_rate = otio_clip.visible_range().duration.rate timecode_fps = round(edit_rate) - tape_timecode_slot = tapemob.create_timecode_slot( - edit_rate=edit_rate, - timecode_fps=timecode_fps, + tape_slot, tape_timecode_slot = tapemob.create_tape_slots( + otio_clip.name, + edit_rate=otio_clip.visible_range().duration.rate, + timecode_fps=round(otio_clip.visible_range().duration.rate), drop_frame=(edit_rate != timecode_fps) ) timecode_start = int( @@ -132,9 +141,13 @@ def _unique_tapemob(self, otio_clip): def track_transcriber(self, otio_track): """Return an appropriate _TrackTranscriber given an otio track.""" if otio_track.kind == otio.schema.TrackKind.Video: - transcriber = VideoTrackTranscriber(self, otio_track) + transcriber = VideoTrackTranscriber(self, otio_track, + embed_essence=self.embed_essence, + create_edgecode=self.create_edgecode) elif otio_track.kind == otio.schema.TrackKind.Audio: - transcriber = AudioTrackTranscriber(self, otio_track) + transcriber = AudioTrackTranscriber(self, otio_track, + embed_essence=self.embed_essence, + create_edgecode=self.create_edgecode) else: raise otio.exceptions.NotSupportedError( f"Unsupported track kind: {otio_track.kind}") @@ -166,11 +179,11 @@ def validate_metadata(timeline): __check(child, "duration().rate").equals(edit_rate), __check(child, "metadata['AAF']['PointList']"), __check(child, "metadata['AAF']['OperationGroup']['Operation']" - "['DataDefinition']['Name']"), + "['DataDefinition']['Name']"), __check(child, "metadata['AAF']['OperationGroup']['Operation']" - "['Description']"), + "['Description']"), __check(child, "metadata['AAF']['OperationGroup']['Operation']" - "['Name']"), + "['Name']"), __check(child, "metadata['AAF']['CutPoint']") ] all_checks.extend(checks) @@ -270,19 +283,27 @@ class _TrackTranscriber: """ __metaclass__ = abc.ABCMeta - def __init__(self, root_file_transcriber, otio_track): + def __init__(self, root_file_transcriber, otio_track, + embed_essence, create_edgecode): """ _TrackTranscriber Args: - root_file_transcriber: the corresponding 'parent' AAFFileTranscriber object - otio_track: the given otio_track to convert + root_file_transcriber(AAFFileTranscriber): the corresponding 'parent' + AAFFileTranscriber object + otio_track(otio.schema.Track): the given otio_track to convert + embed_essence(bool): if `True`, referenced media files in clips will be + embedded into the AAF file + create_edgecode(bool): if `True` each clip will get an EdgeCode slot + assigned that defines the Avid Frame Count Start / End. """ self.root_file_transcriber = root_file_transcriber self.compositionmob = root_file_transcriber.compositionmob self.aaf_file = root_file_transcriber.aaf_file self.otio_track = otio_track self.edit_rate = self.otio_track.find_children()[0].duration().rate + self.embed_essence = embed_essence + self.create_edgecode = create_edgecode self.timeline_mobslot, self.sequence = self._create_timeline_mobslot() self.timeline_mobslot.name = self.otio_track.name @@ -309,13 +330,13 @@ def transcribe(self, otio_child): @property @abc.abstractmethod - def media_kind(self): + def media_kind(self) -> str: """Return the string for what kind of track this is.""" pass @property @abc.abstractmethod - def _master_mob_slot_id(self): + def _master_mob_slot_id(self) -> int: """ Return the MasterMob Slot ID for the corresponding track media kind """ @@ -327,7 +348,8 @@ def _master_mob_slot_id(self): pass @abc.abstractmethod - def _create_timeline_mobslot(self): + def _create_timeline_mobslot(self) \ + -> Tuple[aaf2.mobslots.TimelineMobSlot, aaf2.components.Sequence]: """ Return a timeline_mobslot and sequence for this track. @@ -340,13 +362,84 @@ def _create_timeline_mobslot(self): pass @abc.abstractmethod - def default_descriptor(self, otio_clip): + def default_descriptor(self, otio_clip) -> aaf2.essence.EssenceDescriptor: pass @abc.abstractmethod - def _transition_parameters(self): + def _transition_parameters(self) -> \ + Tuple[List[aaf2.dictionary.ParameterDef], aaf2.misc.Parameter]: + pass + + @abc.abstractmethod + def _import_essence_for_clip(self, + otio_clip: otio.schema.Clip, + essence_path: Path) \ + -> Tuple[aaf2.mobs.MasterMob, aaf2.mobslots.TimelineMobSlot]: pass + def _copy_essence_for_clip(self, + otio_clip: otio.schema.Clip, + aaf_file_path: Path) \ + -> Tuple[aaf2.mobs.MasterMob, aaf2.mobslots.TimelineMobSlot]: + # get Mob ID and make make sure it's a valid MobID object type + mob_id = self.root_file_transcriber._clip_mob_ids_map.get(otio_clip) + if isinstance(mob_id, str): + urn_str = mob_id + mob_id = aaf2.mobs.MobID() + mob_id.urn = urn_str + + # open source AAF file and copy essence + with aaf2.open(str(aaf_file_path), "r") as src_aaf: + # copy over master mob and essence from source AAF to target AAF + for src_master_mob in src_aaf.content.mastermobs(): + if src_master_mob.mob_id != mob_id: + continue + + # copy the essence data from file src_aaf to target aaf + for i, slot in enumerate(src_master_mob.slots): + if isinstance( + slot, aaf2.mobslots.TimelineMobSlot + ): + # copy essence from file aaf_file_path to target aaf + src_source_mob = slot.segment.mob + essence_data_copy = src_source_mob.essence.copy( + root=self.aaf_file + ) + self.aaf_file.content.essencedata.append(essence_data_copy) + + # copy source mob from file aaf_file_path to target aaf + src_source_mob = slot.segment.mob + source_mob_copy = src_source_mob.copy(root=self.aaf_file) + self.aaf_file.content.mobs.append(source_mob_copy) + break + else: + raise AAFAdapterError( + f"No essence data to copy for MasterMob with " + f"ID '{mob_id}' in media reference AAF file: {aaf_file_path}" + ) + + # copy master mob from file aaf_file_path to target aaf + master_mob_copy = src_master_mob.copy(root=self.aaf_file) + self.aaf_file.content.mobs.append(master_mob_copy) + + # get timeline slot for master mob + for slot in master_mob_copy.slots: + if isinstance( + slot, aaf2.mobslots.TimelineMobSlot + ): + master_mob_copy_tl_slot = slot + break + else: + raise AAFAdapterError(f"No TimelineMobSlot for MasterMob " + f"with ID '{mob_id}'.") + + break + else: + raise AAFAdapterError(f"No matching MasterMob with ID '{mob_id}' " + f"in media reference AAF file: {aaf_file_path}") + + return master_mob_copy, master_mob_copy_tl_slot + def aaf_filler(self, otio_gap): """Convert an otio Gap into an aaf Filler""" length = int(otio_gap.visible_range().duration.value) @@ -354,20 +447,58 @@ def aaf_filler(self, otio_gap): return filler def aaf_sourceclip(self, otio_clip): - """Convert an otio Clip into an aaf SourceClip""" - tapemob, tapemob_slot = self._create_tapemob(otio_clip) - filemob, filemob_slot = self._create_filemob(otio_clip, tapemob, tapemob_slot) - mastermob, mastermob_slot = self._create_mastermob(otio_clip, - filemob, - filemob_slot) + """Convert an OTIO Clip into a pyaaf SourceClip. + If `self.embed_essence` is `True`, we attempt to import / embed + the media reference target URL file into the new AAF as media essence. + + Args: + otio_clip(otio.schema.Clip): input OTIO clip + + Returns: + `aaf2.components.SourceClip` + + """ + if self.embed_essence and not otio_clip.media_reference.is_missing_reference: + # embed essence for clip media + target_path = Path( + otio.url_utils.filepath_from_url(otio_clip.media_reference.target_url) + ) + if not target_path.is_file(): + raise FileNotFoundError(f"Cannot find file to embed essence from: " + f"'{target_path}'") + + if target_path.suffix == ".aaf": + # copy over mobs and essence from existing AAF file + mastermob, mastermob_slot = self._copy_essence_for_clip( + otio_clip, target_path + ) + elif target_path.suffix in (".dnx", ".wav"): + # import essence from clip media reference + mastermob, mastermob_slot = self._import_essence_for_clip( + otio_clip=otio_clip, essence_path=target_path + ) + else: + raise AAFAdapterError( + f"Cannot embed media reference at: '{target_path}'." + f"Only .aaf / .dnx / .wav files are supported." + f"You can add logic to transcode your media for " + f"embedding by implementing a " + f"'{hooks.HOOK_PRE_WRITE_TRANSCRIBE}' hook.") + else: + tapemob, tapemob_slot = self._create_tapemob(otio_clip) + filemob, filemob_slot = self._create_filemob(otio_clip, tapemob, + tapemob_slot) + mastermob, mastermob_slot = self._create_mastermob(otio_clip, + filemob, + filemob_slot) # We need both `start_time` and `duration` # Here `start` is the offset between `first` and `in` values. offset = (otio_clip.visible_range().start_time - otio_clip.available_range().start_time) - start = offset.value - length = otio_clip.visible_range().duration.value + start = int(offset.value) + length = int(otio_clip.visible_range().duration.value) compmob_clip = self.compositionmob.create_source_clip( slot_id=self.timeline_mobslot.slot_id, @@ -379,6 +510,25 @@ def aaf_sourceclip(self, otio_clip): compmob_clip.mob = mastermob compmob_clip.slot = mastermob_slot compmob_clip.slot_id = mastermob_slot.slot_id + + # create edgecode for Avid Frame Count properties + if self.create_edgecode: + ec_tl_slot = self._create_edgecode_timeline_slot( + edit_rate=self.edit_rate, + start=int(otio_clip.available_range().start_time.value), + length=int(otio_clip.available_range().duration.value) + ) + mastermob.slots.append(ec_tl_slot) + + # check if we need to set mark-in / mark-out + if otio_clip.visible_range() != otio_clip.available_range(): + mastermob_slot["MarkIn"].value = int( + otio_clip.visible_range().start_time.value + ) + mastermob_slot["MarkOut"].value = int( + otio_clip.visible_range().end_time_exclusive().value + ) + return compmob_clip def aaf_transition(self, otio_transition): @@ -555,6 +705,40 @@ def _create_mastermob(self, otio_clip, filemob, filemob_slot): mastermob_slot.segment = mastermob_clip return mastermob, mastermob_slot + def _create_edgecode_timeline_slot(self, edit_rate, start, length): + """Creates and edgecode timeline mob slot, which is needed + to set Frame Count Start and Frame Count End values in Avid. + + Args: + aaf_file(aaf2.AAFFile): AAF file handle + edit_rate(Fraction): fractional edit rate + start(int): Frame Count Start frame number + length(int): clip length + + Returns: + aaf2.TimelineMobSlot: edgecode TL mob slot + + """ + edgecode = self.aaf_file.create.EdgeCode() + edgecode.media_kind = "Edgecode" + edgecode["Start"].value = start + edgecode["Length"].value = length + edgecode["AvEdgeType"].value = 3 + edgecode["AvFilmType"].value = 0 + edgecode["FilmKind"].value = "Ft35MM" + edgecode["CodeFormat"].value = "EtNull" + + ec_tl_slot = self.aaf_file.create.TimelineMobSlot(slot_id=20, + edit_rate=edit_rate) + ec_tl_slot.name = "EC1" + ec_tl_slot.segment = edgecode + + # Important magic number from Avid, + # track number has to be 6 otherwise MC will ignore it + ec_tl_slot["PhysicalTrackNumber"].value = 6 + + return ec_tl_slot + class VideoTrackTranscriber(_TrackTranscriber): """Video track kind specialization of TrackTranscriber.""" @@ -634,6 +818,26 @@ def _transition_parameters(self): return [param_byteorder, param_effect_id], opacity_u + def _import_essence_for_clip(self, otio_clip, essence_path): + """Implements DNX video essence import""" + available_range = otio_clip.media_reference.available_range + start = int(available_range.start_time.value) + length = int(available_range.duration.value) + edit_rate = round(available_range.duration.rate) + + # create master mobs + mastermob = self.root_file_transcriber._unique_mastermob(otio_clip) + tape_mob = self.root_file_transcriber._unique_tapemob(otio_clip) + tape_clip = tape_mob.create_source_clip(self._master_mob_slot_id, start=start) + + # import video essence + mastermob_slot = mastermob.import_dnxhd_essence(path=str(essence_path), + edit_rate=edit_rate, + tape=tape_clip, + length=length, + offline=False) + return mastermob, mastermob_slot + class AudioTrackTranscriber(_TrackTranscriber): """Audio track kind specialization of TrackTranscriber.""" @@ -737,6 +941,24 @@ def _transition_parameters(self): return [param_def_level], level + def _import_essence_for_clip(self, otio_clip, essence_path): + """Implements audio essence import""" + available_range = otio_clip.media_reference.available_range + start = int(available_range.start_time.value) + length = int(available_range.duration.value) + + # create mobs + mastermob = self.root_file_transcriber._unique_mastermob(otio_clip) + tape_mob = self.root_file_transcriber._unique_tapemob(otio_clip) + tape_clip = tape_mob.create_source_clip(self._master_mob_slot_id, start=start) + + # import audio essence + mastermob_slot = mastermob.import_audio_essence(path=str(essence_path), + tape=tape_clip, + length=length, + offline=False) + return mastermob, mastermob_slot + class __check: """ diff --git a/src/otio_aaf_adapter/adapters/aaf_adapter/hooks.py b/src/otio_aaf_adapter/adapters/aaf_adapter/hooks.py new file mode 100644 index 0000000..3b0e94c --- /dev/null +++ b/src/otio_aaf_adapter/adapters/aaf_adapter/hooks.py @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +import aaf2 +import opentimelineio as otio + + +# Plugin custom hook names +HOOK_PRE_READ_TRANSCRIBE = "otio_aaf_pre_read_transcribe" +HOOK_POST_READ_TRANSCRIBE = "otio_aaf_post_read_transcribe" +HOOK_PRE_WRITE_TRANSCRIBE = "otio_aaf_pre_write_transcribe" +HOOK_POST_WRITE_TRANSCRIBE = "otio_aaf_post_write_transcribe" + + +def run_pre_write_transcribe_hook( + timeline: otio.schema.Timeline, + write_filepath: str, + aaf_handle: aaf2.file.AAFFile, + embed_essence: bool, + extra_kwargs: dict +) -> otio.schema.Timeline: + """This hook runs on write, just before the timeline got translated to pyaaf2 + data.""" + if HOOK_PRE_WRITE_TRANSCRIBE in otio.hooks.names(): + extra_kwargs.update({ + "write_filepath": write_filepath, + "aaf_handle": aaf_handle, + "embed_essence": embed_essence, + }) + return otio.hooks.run(HOOK_PRE_WRITE_TRANSCRIBE, timeline, extra_kwargs) + return timeline + + +def run_post_write_transcribe_hook( + timeline: otio.schema.Timeline, + write_filepath: str, + aaf_handle: aaf2.file.AAFFile, + embed_essence: bool, + extra_kwargs: dict +) -> otio.schema.Timeline: + """This hook runs on write, just after the timeline gets translated to pyaaf2 data. + """ + if HOOK_POST_WRITE_TRANSCRIBE in otio.hooks.names(): + extra_kwargs.update({ + "write_filepath": write_filepath, + "aaf_handle": aaf_handle, + "embed_essence": embed_essence, + }) + return otio.hooks.run(HOOK_POST_WRITE_TRANSCRIBE, timeline, extra_kwargs) + return timeline + + +def run_pre_read_transcribe_hook( + read_filepath: str, + aaf_handle: aaf2.file.AAFFile, + extra_kwargs: dict +) -> None: + """This hook runs on read, just before the timeline gets translated from pyaaf2 + to OTIO data. It can be useful to manipulate the AAF data directly before the + transcribing occurs. The hook doesn't return a timeline, since it runs before the + Timeline object has been transcribed.""" + if HOOK_PRE_WRITE_TRANSCRIBE in otio.hooks.names(): + extra_kwargs.update({ + "read_filepath": read_filepath, + "aaf_handle": aaf_handle, + }) + otio.hooks.run(HOOK_PRE_READ_TRANSCRIBE, tl=None, extra_args=extra_kwargs) + + +def run_post_read_transcribe_hook( + timeline: otio.schema.Timeline, + read_filepath: str, + aaf_handle: aaf2.file.AAFFile, + extra_kwargs: dict +) -> otio.schema.Timeline: + """This hook runs on read, just after the timeline got translated to OTIO data, + but before it is simplified. Possible use cases could be logic to extract and + transcode media from the AAF. + """ + if HOOK_POST_WRITE_TRANSCRIBE in otio.hooks.names(): + extra_kwargs.update({ + "read_filepath": read_filepath, + "aaf_handle": aaf_handle + }) + return otio.hooks.run(HOOK_POST_WRITE_TRANSCRIBE, + tl=timeline, + extra_args=extra_kwargs) + return timeline diff --git a/src/otio_aaf_adapter/adapters/advanced_authoring_format.py b/src/otio_aaf_adapter/adapters/advanced_authoring_format.py index eb60a8d..a64b9aa 100644 --- a/src/otio_aaf_adapter/adapters/advanced_authoring_format.py +++ b/src/otio_aaf_adapter/adapters/advanced_authoring_format.py @@ -14,6 +14,8 @@ import collections import fractions +from typing import List + import opentimelineio as otio lib_path = os.environ.get("OTIO_AAF_PYTHON_LIB") @@ -27,6 +29,7 @@ import aaf2.core # noqa: E402 import aaf2.misc # noqa: E402 from otio_aaf_adapter.adapters.aaf_adapter import aaf_writer # noqa: E402 +from otio_aaf_adapter.adapters.aaf_adapter import hooks # noqa: E402 debug = False @@ -266,35 +269,36 @@ def _convert_rgb_to_marker_color(rgb_dict): def _find_timecode_mobs(item): mobs = [item.mob] - - for c in item.walk(): - if isinstance(c, aaf2.components.SourceClip): - mob = c.mob - if mob: - mobs.append(mob) + try: + for c in item.walk(): + if isinstance(c, aaf2.components.SourceClip): + mob = c.mob + if mob: + mobs.append(mob) + else: + continue else: + # This could be 'EssenceGroup', 'Pulldown' or other segment + # subclasses + # For example: + # An EssenceGroup is a Segment that has one or more + # alternate choices, each of which represent different variations + # of one actual piece of content. + # According to the AAF Object Specification and Edit Protocol + # documents: + # "Typically the different representations vary in essence format, + # compression, or frame size. The application is responsible for + # choosing the appropriate implementation of the essence." + # It also says they should all have the same length, but + # there might be nested Sequences inside which we're not attempting + # to handle here (yet). We'll need a concrete example to ensure + # we're doing the right thing. + # TODO: Is the Timecode for an EssenceGroup correct? + # TODO: Try CountChoices() and ChoiceAt(i) + # For now, lets just skip it. continue - else: - # This could be 'EssenceGroup', 'Pulldown' or other segment - # subclasses - # For example: - # An EssenceGroup is a Segment that has one or more - # alternate choices, each of which represent different variations - # of one actual piece of content. - # According to the AAF Object Specification and Edit Protocol - # documents: - # "Typically the different representations vary in essence format, - # compression, or frame size. The application is responsible for - # choosing the appropriate implementation of the essence." - # It also says they should all have the same length, but - # there might be nested Sequences inside which we're not attempting - # to handle here (yet). We'll need a concrete example to ensure - # we're doing the right thing. - # TODO: Is the Timecode for an EssenceGroup correct? - # TODO: Try CountChoices() and ChoiceAt(i) - # For now, lets just skip it. - continue - + except NotImplementedError as err: + _transcribe_log(f"Couldn't walk component. Skip:\n{repr(err)}") return mobs @@ -1576,25 +1580,23 @@ def _get_mobs_for_transcription(storage): def read_from_file( - filepath, - simplify=True, - transcribe_log=False, - attach_markers=True, - bake_keyframed_properties=False -): + filepath: str, + simplify: bool = True, + transcribe_log: bool = False, + attach_markers: bool = True, + bake_keyframed_properties: bool = False, + **kwargs +) -> otio.schema.Timeline: """Reads AAF content from `filepath` and outputs an OTIO timeline object. Args: - filepath (str): AAF filepath - simplify (bool, optional): simplify timeline structure by stripping empty items - transcribe_log (bool, optional): log activity as items are getting transcribed - attach_markers (bool, optional): attaches markers to their appropriate items + filepath: AAF filepath + simplify: simplify timeline structure by stripping empty items + transcribe_log: log activity as items are getting transcribed + attach_markers: attaches markers to their appropriate items like clip, gap. etc on the track - bake_keyframed_properties (bool, optional): bakes animated property values - for each frame in a source clip - Returns: - otio.schema.Timeline - + bake_keyframed_properties: bakes animated property values + for each frame in a source clip """ # 'activate' transcribe logging if adapter argument is provided. # Note that a global 'switch' is used in order to avoid @@ -1608,41 +1610,86 @@ def read_from_file( # Note: We're skipping: aaf_file.header # Is there something valuable in there? + # trigger adapter specific pre-transcribe read hook + hooks.run_pre_read_transcribe_hook( + read_filepath=filepath, + aaf_handle=aaf_file, + extra_kwargs=kwargs.get( + "hook_function_argument_map", {} + ) + ) + storage = aaf_file.content mobs_to_transcribe = _get_mobs_for_transcription(storage) - result = _transcribe(mobs_to_transcribe, parents=list(), edit_rate=None) + timeline = _transcribe(mobs_to_transcribe, parents=list(), edit_rate=None) + + # trigger adapter specific post-transcribe read hook + hooks.run_post_read_transcribe_hook( + timeline=timeline, + read_filepath=filepath, + aaf_handle=aaf_file, + extra_kwargs=kwargs.get( + "hook_function_argument_map", {} + ) + ) # OTIO represents transitions a bit different than AAF, so # we need to iterate over them and modify the items on either side. # Note this needs to be done before attaching markers, marker # positions are not stored with transition length offsets - _fix_transitions(result) + _fix_transitions(timeline) # Attach marker to the appropriate clip, gap etc. if attach_markers: - result = _attach_markers(result) + timeline = _attach_markers(timeline) # AAF is typically more deeply nested than OTIO. # Let's try to simplify the structure by collapsing or removing # unnecessary stuff. if simplify: - result = _simplify(result) + timeline = _simplify(timeline) # Reset transcribe_log debugging _TRANSCRIBE_DEBUG = False - return result + return timeline -def write_to_file(input_otio, filepath, **kwargs): +def write_to_file(input_otio, filepath, embed_essence=False, + create_edgecode=True, **kwargs): + """Serialize `input_otio` to an AAF file at `filepath`. + + Args: + input_otio(otio.schema.Timeline): input timeline + filepath(str): output filepath + embed_essence(Optional[bool]): if `True`, media essence will be included in AAF + create_edgecode(bool): if `True` each clip will get an EdgeCode slot + assigned that defines the Avid Frame Count Start / End. + **kwargs: extra adapter arguments + + """ with aaf2.open(filepath, "w") as f: + # trigger adapter specific pre-transcribe write hook + hook_tl = hooks.run_pre_write_transcribe_hook( + timeline=input_otio, + write_filepath=filepath, + aaf_handle=f, + embed_essence=embed_essence, + extra_kwargs=kwargs.get( + "hook_function_argument_map", {} + ) + ) - timeline = aaf_writer._stackify_nested_groups(input_otio) + timeline = aaf_writer._stackify_nested_groups(hook_tl) aaf_writer.validate_metadata(timeline) - otio2aaf = aaf_writer.AAFFileTranscriber(timeline, f, **kwargs) + otio2aaf = aaf_writer.AAFFileTranscriber(input_otio=timeline, + aaf_file=f, + embed_essence=embed_essence, + create_edgecode=create_edgecode, + **kwargs) if not isinstance(timeline, otio.schema.Timeline): raise otio.exceptions.NotSupportedError( @@ -1659,3 +1706,24 @@ def write_to_file(input_otio, filepath, **kwargs): result = transcriber.transcribe(otio_child) if result: transcriber.sequence.components.append(result) + + # trigger adapter specific post-transcribe write hook + hooks.run_post_write_transcribe_hook( + timeline=timeline, + write_filepath=filepath, + aaf_handle=f, + embed_essence=embed_essence, + extra_kwargs=kwargs.get( + "hook_function_argument_map", {} + ) + ) + + +def adapter_hook_names() -> List[str]: + """Returns names of custom hooks implemented by this adapter.""" + return [ + hooks.HOOK_POST_READ_TRANSCRIBE, + hooks.HOOK_POST_WRITE_TRANSCRIBE, + hooks.HOOK_PRE_READ_TRANSCRIBE, + hooks.HOOK_PRE_WRITE_TRANSCRIBE + ] diff --git a/src/otio_aaf_adapter/plugin_manifest.json b/src/otio_aaf_adapter/plugin_manifest.json index 8c6a9b1..93fdeb1 100644 --- a/src/otio_aaf_adapter/plugin_manifest.json +++ b/src/otio_aaf_adapter/plugin_manifest.json @@ -8,5 +8,11 @@ "filepath" : "adapters/advanced_authoring_format.py", "suffixes" : ["aaf"] } - ] + ], + "hooks" : { + "otio_aaf_pre_read_transcribe": [], + "otio_aaf_post_read_transcribe": [], + "otio_aaf_pre_write_transcribe": [], + "otio_aaf_post_write_transcribe": [] + } } diff --git a/tests/hooks_plugin_example/plugin_manifest.json b/tests/hooks_plugin_example/plugin_manifest.json new file mode 100644 index 0000000..88009bc --- /dev/null +++ b/tests/hooks_plugin_example/plugin_manifest.json @@ -0,0 +1,35 @@ +{ + "OTIO_SCHEMA" : "PluginManifest.1", + "hook_scripts" : [ + { + "OTIO_SCHEMA" : "HookScript.1", + "name" : "pre_aaf_write_transcribe_hook", + "execution_scope" : "in process", + "filepath" : "pre_aaf_write_transcribe_hook.py" + }, + { + "OTIO_SCHEMA" : "HookScript.1", + "name" : "post_aaf_write_transcribe_hook", + "execution_scope" : "in process", + "filepath" : "post_aaf_write_transcribe_hook.py" + }, + { + "OTIO_SCHEMA" : "HookScript.1", + "name" : "pre_aaf_read_transcribe_hook", + "execution_scope" : "in process", + "filepath" : "pre_aaf_read_transcribe_hook.py" + }, + { + "OTIO_SCHEMA" : "HookScript.1", + "name" : "post_aaf_read_transcribe_hook", + "execution_scope" : "in process", + "filepath" : "post_aaf_read_transcribe_hook.py" + } + ], + "hooks" : { + "otio_aaf_pre_write_transcribe": ["pre_aaf_write_transcribe_hook"], + "otio_aaf_post_write_transcribe": ["post_aaf_write_transcribe_hook"], + "otio_aaf_pre_read_transcribe": [], + "otio_aaf_post_read_transcribe": [] + } +} \ No newline at end of file diff --git a/tests/hooks_plugin_example/post_aaf_read_transcribe_hook.py b/tests/hooks_plugin_example/post_aaf_read_transcribe_hook.py new file mode 100644 index 0000000..97dbd2c --- /dev/null +++ b/tests/hooks_plugin_example/post_aaf_read_transcribe_hook.py @@ -0,0 +1,12 @@ +"""Example hook that runs post-transcription on a read operation. +This hook could be used to extract and transcode essence data from the AAF for +consumption outside of Avid MC. +""" +from otio_aaf_adapter.adapters.aaf_adapter.aaf_writer import AAFAdapterError + + +def hook_function(in_timeline, argument_map=None): + if argument_map.get("test_post_hook_raise", False): + raise AAFAdapterError() + + return in_timeline diff --git a/tests/hooks_plugin_example/post_aaf_write_transcribe_hook.py b/tests/hooks_plugin_example/post_aaf_write_transcribe_hook.py new file mode 100644 index 0000000..9e6ae60 --- /dev/null +++ b/tests/hooks_plugin_example/post_aaf_write_transcribe_hook.py @@ -0,0 +1,21 @@ +"""Example hook that runs post-transcription on a write operation. +This hook is useful to clean up temporary files or metadata post-write. +""" +from otio_aaf_adapter.adapters.aaf_adapter.aaf_writer import AAFAdapterError + + +def hook_function(in_timeline, argument_map=None): + if argument_map.get("test_post_hook_raise", False): + raise AAFAdapterError() + + if not argument_map.get("embed_essence", False): + # no essence embedding requested, skip the hook + return in_timeline + + for clip in in_timeline.find_clips(): + # reset target URL to pre-conversion media, remove metadata + original_url = clip.media_reference.metadata.pop("original_target_url") + if original_url: + clip.media_reference.target_url = original_url + + return in_timeline diff --git a/tests/hooks_plugin_example/pre_aaf_read_transcribe_hook.py b/tests/hooks_plugin_example/pre_aaf_read_transcribe_hook.py new file mode 100644 index 0000000..b25830b --- /dev/null +++ b/tests/hooks_plugin_example/pre_aaf_read_transcribe_hook.py @@ -0,0 +1,12 @@ +"""Example hook that runs pre-transcription on a read operation. +This can be useful for just-in-time modification of the AAF structure prior to +transcription. +""" +from otio_aaf_adapter.adapters.aaf_adapter.aaf_writer import AAFAdapterError + + +def hook_function(in_timeline, argument_map=None): + if argument_map.get("test_pre_hook_raise", False): + raise AAFAdapterError() + + return in_timeline diff --git a/tests/hooks_plugin_example/pre_aaf_write_transcribe_hook.py b/tests/hooks_plugin_example/pre_aaf_write_transcribe_hook.py new file mode 100644 index 0000000..5e14509 --- /dev/null +++ b/tests/hooks_plugin_example/pre_aaf_write_transcribe_hook.py @@ -0,0 +1,27 @@ +"""Example hook that runs pre-transcription on a write operation. +This can be useful for just-in-time transcoding of media references to DNX data / +WAVE audio files. +""" +import os +from pathlib import Path +from otio_aaf_adapter.adapters.aaf_adapter.aaf_writer import AAFAdapterError + + +def hook_function(in_timeline, argument_map=None): + if argument_map.get("test_pre_hook_raise", False): + raise AAFAdapterError() + + if not argument_map.get("embed_essence", False): + # no essence embedding requested, skip the hook + return in_timeline + + for clip in in_timeline.find_clips(): + # mock convert video media references, this could be done with ffmpeg + if Path(clip.media_reference.target_url).suffix == ".mov": + converted_url = Path(clip.media_reference.target_url).with_suffix(".dnx") + clip.media_reference.metadata[ + "original_target_url" + ] = clip.media_reference.target_url + clip.media_reference.target_url = os.fspath(converted_url) + + return in_timeline diff --git a/tests/sample_data/picchu_sample.wav b/tests/sample_data/picchu_sample.wav new file mode 100644 index 0000000..2d9eb9b Binary files /dev/null and b/tests/sample_data/picchu_sample.wav differ diff --git a/tests/sample_data/picchu_seq0100_snippet_dnx.dnx b/tests/sample_data/picchu_seq0100_snippet_dnx.dnx new file mode 100644 index 0000000..3967fb7 Binary files /dev/null and b/tests/sample_data/picchu_seq0100_snippet_dnx.dnx differ diff --git a/tests/sample_data/picchu_seq0100_snippet_dnx.mov b/tests/sample_data/picchu_seq0100_snippet_dnx.mov new file mode 100644 index 0000000..5bc7ae8 Binary files /dev/null and b/tests/sample_data/picchu_seq0100_snippet_dnx.mov differ diff --git a/tests/sample_data/picchu_seq0100_snippet_embedded.aaf b/tests/sample_data/picchu_seq0100_snippet_embedded.aaf new file mode 100644 index 0000000..bcec38d Binary files /dev/null and b/tests/sample_data/picchu_seq0100_snippet_embedded.aaf differ diff --git a/tests/test_aaf_adapter.py b/tests/test_aaf_adapter.py index 167170d..6531dbe 100644 --- a/tests/test_aaf_adapter.py +++ b/tests/test_aaf_adapter.py @@ -9,12 +9,15 @@ import unittest import tempfile import io +import contextlib +from pathlib import Path import opentimelineio as otio from otio_aaf_adapter.adapters.aaf_adapter.aaf_writer import ( AAFAdapterError, AAFValidationError ) +from otio_aaf_adapter.adapters.aaf_adapter import hooks # module needs to be imported for code coverage to work import otio_aaf_adapter.adapters.advanced_authoring_format # noqa: F401 @@ -230,7 +233,8 @@ Transition, Timecode, OperationGroup, - Sequence) + Sequence, + EdgeCode) from aaf2.mobs import MasterMob, SourceMob from aaf2.misc import VaryingValue could_import_aaf = True @@ -1658,7 +1662,24 @@ def test_non_av_track_kind(self): ) +@contextlib.contextmanager +def with_hooks_plugin_environment(): + env_bkp = os.environ.copy() + try: + os.environ["OTIO_PLUGIN_MANIFEST_PATH"] = ( + os.fspath( + Path(__file__).parent / "hooks_plugin_example/plugin_manifest.json" + ) + ) + otio.plugins.manifest.ActiveManifest(force_reload=True) + yield + finally: + os.environ = env_bkp + otio.plugins.manifest.ActiveManifest(force_reload=True) + + class AAFWriterTests(unittest.TestCase): + def test_aaf_writer_gaps(self): otio_timeline = otio.adapters.read_from_file(GAPS_OTIO_PATH) fd, tmp_aaf_path = tempfile.mkstemp(suffix='.aaf') @@ -1871,33 +1892,40 @@ def _verify_compositionmob_sourceclip_structure(self, compmob_clip): self.assertTrue(isinstance(compmob_clip.mob, MasterMob)) mastermob = compmob_clip.mob for mastermob_slot in mastermob.slots: - mastermob_clip = mastermob_slot.segment - self.assertTrue(isinstance(mastermob_clip, SourceClip)) - self.assertTrue(isinstance(mastermob_clip.mob, SourceMob)) - filemob = mastermob_clip.mob - - self.assertEqual(1, len(filemob.slots)) - filemob_clip = filemob.slots[0].segment - - self.assertTrue(isinstance(filemob_clip, SourceClip)) - self.assertTrue(isinstance(filemob_clip.mob, SourceMob)) - tapemob = filemob_clip.mob - self.assertTrue(len(tapemob.slots) >= 2) - - timecode_slots = [tape_slot for tape_slot in tapemob.slots - if isinstance(tape_slot.segment, - Timecode)] - - self.assertEqual(1, len(timecode_slots)) - - for tape_slot in tapemob.slots: - tapemob_component = tape_slot.segment - if not isinstance(tapemob_component, Timecode): - self.assertTrue(isinstance(tapemob_component, SourceClip)) - tapemob_clip = tapemob_component - self.assertEqual(None, tapemob_clip.mob) - self.assertEqual(None, tapemob_clip.slot) - self.assertEqual(0, tapemob_clip.slot_id) + mastermob_segment = mastermob_slot.segment + self.assertTrue(isinstance(mastermob_segment, (SourceClip, EdgeCode))) + + if isinstance(mastermob_segment, SourceClip): + self.assertTrue(isinstance(mastermob_segment.mob, SourceMob)) + filemob = mastermob_segment.mob + + self.assertEqual(1, len(filemob.slots)) + filemob_clip = filemob.slots[0].segment + + self.assertTrue(isinstance(filemob_clip, SourceClip)) + self.assertTrue(isinstance(filemob_clip.mob, SourceMob)) + tapemob = filemob_clip.mob + self.assertTrue(len(tapemob.slots) >= 2) + + timecode_slots = [tape_slot for tape_slot in tapemob.slots + if isinstance(tape_slot.segment, + Timecode)] + + self.assertEqual(1, len(timecode_slots)) + + for tape_slot in tapemob.slots: + tapemob_component = tape_slot.segment + if not isinstance(tapemob_component, Timecode): + self.assertTrue(isinstance(tapemob_component, SourceClip)) + tapemob_clip = tapemob_component + self.assertEqual(None, tapemob_clip.mob) + self.assertEqual(None, tapemob_clip.slot) + self.assertEqual(0, tapemob_clip.slot_id) + elif isinstance(mastermob_segment, EdgeCode): + self.assertEqual(mastermob_segment["AvEdgeType"].value, 3) + self.assertEqual(mastermob_segment["AvFilmType"].value, 0) + self.assertEqual(mastermob_segment["FilmKind"].value, "Ft35MM") + self.assertEqual(mastermob_segment["CodeFormat"].value, "EtNull") def _is_otio_aaf_same(self, otio_child, aaf_component): if isinstance(aaf_component, SourceClip): @@ -1920,6 +1948,304 @@ def _is_otio_aaf_same(self, otio_child, aaf_component): self.assertEqual(orig_point["Value"], dest_point.value) self.assertEqual(orig_point["Time"], dest_point.time) + def test_transcribe_hooks_registry(self): + """Tests if the hook example correctly registers with OTIO.""" + with with_hooks_plugin_environment(): + for hook_script in ["post_aaf_write_transcribe_hook", + "pre_aaf_write_transcribe_hook", + "post_aaf_read_transcribe_hook", + "pre_aaf_read_transcribe_hook"]: + self.assertIn( + hook_script, + otio.plugins.plugin_info_map()["hook_scripts"] + ) + + for hook_name in [hooks.HOOK_PRE_WRITE_TRANSCRIBE, + hooks.HOOK_POST_WRITE_TRANSCRIBE, + hooks.HOOK_PRE_READ_TRANSCRIBE, + hooks.HOOK_POST_READ_TRANSCRIBE]: + self.assertIn( + hook_name, + otio.plugins.plugin_info_map()["hooks"] + ) + + def test_transcribe_hook_args_map(self): + """Tests if extra arguments are correctly passed to the hooks. + """ + + tl = otio.schema.Timeline(tracks=[]) + _, tmp_aaf_path = tempfile.mkstemp(suffix='.aaf') + with with_hooks_plugin_environment(): + with self.assertRaises(AAFAdapterError): + otio.adapters.write_to_file( + tl, + filepath=tmp_aaf_path, + embed_essence=True, + use_empty_mob_ids=True, + hook_function_argument_map={ + "test_pre_hook_raise": True + } + ) + + with self.assertRaises(AAFAdapterError): + otio.adapters.write_to_file( + tl, + filepath=tmp_aaf_path, + embed_essence=True, + use_empty_mob_ids=True, + hook_function_argument_map={ + "test_post_hook_raise": True + } + ) + + def test_transcribe_embed_dnx_data(self): + """Tests simple DNX image data essence import.""" + range = otio.opentime.TimeRange( + start_time=otio.opentime.RationalTime( + value=1, + rate=24.0 + ), + duration=otio.opentime.RationalTime( + value=24, + rate=24.0 + ) + ) + media_reference_dnx = otio.schema.ExternalReference( + target_url=os.fspath(Path(SAMPLE_DATA_DIR) / + "picchu_seq0100_snippet_dnx.dnx"), + available_range=range + ) + dnx_clip = otio.schema.Clip( + name="EmbeddedClip", + source_range=range, + media_reference=media_reference_dnx + ) + + track = otio.schema.Track(children=[dnx_clip], kind=otio.schema.TrackKind.Video) + tl = otio.schema.Timeline(tracks=[track]) + + _, tmp_aaf_path = tempfile.mkstemp(prefix="embed_dnx_", suffix='.aaf') + + otio.adapters.write_to_file( + tl, + filepath=tmp_aaf_path, + embed_essence=True, + use_empty_mob_ids=True, + ) + + self._assertEssenceAAF(tl, tmp_aaf_path) + + def test_transcribe_embed_wav_audio(self): + """Tests simple WAV audio essence import.""" + range = otio.opentime.TimeRange( + start_time=otio.opentime.RationalTime( + value=0, + rate=24.0 + ), + duration=otio.opentime.RationalTime( + value=96, + rate=24.0 + ) + ) + media_reference_wav = otio.schema.ExternalReference( + target_url=os.fspath(Path(SAMPLE_DATA_DIR) / "picchu_sample.wav"), + available_range=range + ) + wav_clip = otio.schema.Clip( + name="EmbeddedClip", + source_range=range, + media_reference=media_reference_wav + ) + + track = otio.schema.Track(children=[wav_clip], kind=otio.schema.TrackKind.Audio) + tl = otio.schema.Timeline(tracks=[track]) + + _, tmp_aaf_path = tempfile.mkstemp(prefix="embed_wav_", suffix='.aaf') + + otio.adapters.write_to_file( + tl, + filepath=tmp_aaf_path, + embed_essence=True, + use_empty_mob_ids=True, + ) + + self._assertEssenceAAF(tl, tmp_aaf_path) + + def test_transcribe_embed_aaf_clip_mob_id(self): + """Tests simple AAF essence import with the Mob ID for the MasterMob stored + on the clip.""" + range = otio.opentime.TimeRange( + start_time=otio.opentime.RationalTime( + value=0, + rate=24.0 + ), + duration=otio.opentime.RationalTime( + value=24, + rate=24.0 + ) + ) + media_reference_aaf = otio.schema.ExternalReference( + target_url=os.fspath(Path(SAMPLE_DATA_DIR) / + "picchu_seq0100_snippet_embedded.aaf"), + available_range=range + ) + aaf_clip = otio.schema.Clip( + name="EmbeddedClip", + source_range=range, + media_reference=media_reference_aaf + ) + + aaf_clip.metadata["AAF"] = { + "SourceID": + "urn:smpte:umid:060a2b34.01010105.01010f20.13000000." + "d118caad.97b44c06.807ef723.fd32dc64" + } + + track = otio.schema.Track(children=[aaf_clip], kind=otio.schema.TrackKind.Video) + tl = otio.schema.Timeline(tracks=[track]) + + _, tmp_aaf_path = tempfile.mkstemp(prefix="embed_aaf_clip_", suffix='.aaf') + + otio.adapters.write_to_file( + tl, + filepath=tmp_aaf_path, + embed_essence=True + ) + + self._assertEssenceAAF(tl, tmp_aaf_path) + + def test_transcribe_embed_aaf_media_ref_mob_id(self): + """Tests simple AAF essenceimport with the Mob ID for the MasterMob stored + on the media reference.""" + range = otio.opentime.TimeRange( + start_time=otio.opentime.RationalTime( + value=0, + rate=24.0 + ), + duration=otio.opentime.RationalTime( + value=24, + rate=24.0 + ) + ) + media_reference_aaf = otio.schema.ExternalReference( + target_url=os.fspath(Path(SAMPLE_DATA_DIR) / + "picchu_seq0100_snippet_embedded.aaf"), + available_range=range + ) + aaf_clip = otio.schema.Clip( + name="EmbeddedClip", + source_range=range, + media_reference=media_reference_aaf + ) + + media_reference_aaf.metadata["AAF"] = { + "SourceID": + "urn:smpte:umid:060a2b34.01010105.01010f20.13000000." + "d118caad.97b44c06.807ef723.fd32dc64" + } + + track = otio.schema.Track(children=[aaf_clip], kind=otio.schema.TrackKind.Video) + tl = otio.schema.Timeline(tracks=[track]) + + _, tmp_aaf_path = tempfile.mkstemp(prefix="embed_aaf_mediaref_", suffix='.aaf') + + otio.adapters.write_to_file( + tl, + filepath=tmp_aaf_path, + embed_essence=True + ) + + self._assertEssenceAAF(tl, tmp_aaf_path) + + def test_transcribe_embed_mov_format_failure(self): + """Checks if embedding fails when external reference media isn't supported / + transcoded. + """ + range = otio.opentime.TimeRange( + start_time=otio.opentime.RationalTime( + value=0, + rate=24.0 + ), + duration=otio.opentime.RationalTime( + value=24, + rate=24.0 + ) + ) + + media_reference_mov = otio.schema.ExternalReference( + target_url=os.fspath(Path(SAMPLE_DATA_DIR) / + "picchu_seq0100_snippet_dnx.mov"), + available_range=range + ) + mov_clip = otio.schema.Clip( + name="EmbeddedClip", + source_range=range, + media_reference=media_reference_mov + ) + + track = otio.schema.Track(children=[mov_clip], kind=otio.schema.TrackKind.Video) + tl = otio.schema.Timeline(tracks=[track]) + + _, tmp_aaf_path = tempfile.mkstemp(prefix="embed_mov_failure_", suffix='.aaf') + + with self.assertRaises(AAFAdapterError): + otio.adapters.write_to_file( + tl, + filepath=tmp_aaf_path, + embed_essence=True, + use_empty_mob_ids=True) + + def test_transcribe_embed_mov_with_transcode_hook(self): + """Checks if a mov import works when run with a mocked transcoding hook. + """ + range = otio.opentime.TimeRange( + start_time=otio.opentime.RationalTime( + value=0, + rate=24.0 + ), + duration=otio.opentime.RationalTime( + value=24, + rate=24.0 + ) + ) + + media_reference_mov = otio.schema.ExternalReference( + target_url=os.fspath(Path(SAMPLE_DATA_DIR) / + "picchu_seq0100_snippet_dnx.mov"), + available_range=range + ) + mov_clip = otio.schema.Clip( + name="EmbeddedClip", + source_range=range, + media_reference=media_reference_mov + ) + + track = otio.schema.Track(children=[mov_clip], kind=otio.schema.TrackKind.Video) + tl = otio.schema.Timeline(tracks=[track]) + + _, tmp_aaf_path = tempfile.mkstemp(prefix="embed_mov_hook_", suffix='.aaf') + + with with_hooks_plugin_environment(): + otio.adapters.write_to_file( + tl, + filepath=tmp_aaf_path, + embed_essence=True, + use_empty_mob_ids=True) + + def _assertEssenceAAF(self, reference_tl, tmp_aaf_path): + self.assertTrue(Path(tmp_aaf_path).is_file()) + + with aaf2.open(tmp_aaf_path) as aaf_file: + self.assertEqual(len(aaf_file.content.essencedata), 1) + + aaf_tl = otio.adapters.read_from_file(tmp_aaf_path) + aaf_clips = {c.name: c for c in aaf_tl.find_clips()} + + for ref_clip in reference_tl.find_clips(): + aaf_clip = aaf_clips.get(ref_clip.name) + self.assertIsNotNone(aaf_clip) + self.assertEqual(aaf_clip.source_range, ref_clip.source_range) + class SimplifyTests(unittest.TestCase): def test_aaf_simplify(self):