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..8481235 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( @@ -40,8 +45,8 @@ def _is_considered_gap(thing): if ( isinstance(thing, otio.schema.Clip) and isinstance( - thing.media_reference, - otio.schema.GeneratorReference) + thing.media_reference, + otio.schema.GeneratorReference) ): if thing.media_reference.generator_kind in ("Slug",): return True @@ -70,15 +75,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 +121,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 +143,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 +181,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 +285,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 +332,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 +350,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 +364,65 @@ 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]: + # TODO: this needs proper testing + mob_id = self.root_file_transcriber._clip_mob_ids_map.get(otio_clip) + with aaf2.open(str(aaf_file_path), "r") as src_aaf: + # copy over master mob and essence from source AAF to target AAF + for mob in src_aaf.content.mobs: + if mob.mob_id != mob_id: + continue + + # copy master mob from file src_aaf to target aaf + mob_copy = mob.copy(root=self.aaf_file) + self.aaf_file.content.mobs.append(mob_copy) + mastermob = mob_copy + + # get timeline slot for master mob + for slot in mastermob.slots: + if isinstance( + slot, aaf2.mobslots.TimelineMobSlot + ): + mastermob_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}") + + # copy over essence from source AAF to target AAF + for essence_data in src_aaf.content.essencedata: + if essence_data.mob_id != mob_id: + continue + + # copy the essence data from file src_aaf to target aaf + essence_data_copy = essence_data.copy(root=self.aaf_file) + self.aaf_file.content.essencedata.append(essence_data_copy) + break + else: + raise AAFAdapterError(f"No matching essence for Mob ID '{mob_id}' " + f"in media reference AAF file: {aaf_file_path}") + + return mastermob, mastermob_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 +430,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_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 +493,24 @@ 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 +687,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 +800,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 +923,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..21dc9c5 --- /dev/null +++ b/src/otio_aaf_adapter/adapters/aaf_adapter/hooks.py @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +import aaf2 +import opentimelineio as otio + + +# Plugin custom hook names +HOOK_PRE_TRANSCRIBE = "otio_aaf_pre_transcribe" +HOOK_POST_TRANSCRIBE = "otio_aaf_post_transcribe" + +def run_pre_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 gets translated to pyaaf2 + data.""" + if HOOK_PRE_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_TRANSCRIBE, timeline, extra_kwargs) + return timeline + + +def run_post_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_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_TRANSCRIBE, timeline, 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 8eb14f2..350351f 100644 --- a/src/otio_aaf_adapter/adapters/advanced_authoring_format.py +++ b/src/otio_aaf_adapter/adapters/advanced_authoring_format.py @@ -27,6 +27,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 debug = False @@ -1565,7 +1566,8 @@ def read_from_file( simplify=True, transcribe_log=False, attach_markers=True, - bake_keyframed_properties=False + bake_keyframed_properties=False, + **kwargs ): """Reads AAF content from `filepath` and outputs an OTIO timeline object. @@ -1619,15 +1621,40 @@ def read_from_file( return result -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`. - with aaf2.open(filepath, "w") as f: + 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 + + """ - timeline = aaf_writer._stackify_nested_groups(input_otio) + with aaf2.open(filepath, "w") as f: + print(f"ADAPTER KWARGS: {kwargs}") + # trigger adapter specific pre-transcribe write hook + hook_tl = hooks.run_pre_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(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( @@ -1644,3 +1671,17 @@ 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_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(): + """Returns names of custom hooks implemented by this adapter.""" + return ["otio_aaf_pre_transcribe", "otio_aaf_post_transcribe"] diff --git a/src/otio_aaf_adapter/plugin_manifest.json b/src/otio_aaf_adapter/plugin_manifest.json index 8c6a9b1..5992508 100644 --- a/src/otio_aaf_adapter/plugin_manifest.json +++ b/src/otio_aaf_adapter/plugin_manifest.json @@ -8,5 +8,9 @@ "filepath" : "adapters/advanced_authoring_format.py", "suffixes" : ["aaf"] } - ] + ], + "hooks" : { + "otio_aaf_pre_transcribe": [], + "otio_aaf_post_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..d4cd10d --- /dev/null +++ b/tests/hooks_plugin_example/plugin_manifest.json @@ -0,0 +1,21 @@ +{ + "OTIO_SCHEMA" : "PluginManifest.1", + "hook_scripts" : [ + { + "OTIO_SCHEMA" : "HookScript.1", + "name" : "pre_aaf_transcribe_hook", + "execution_scope" : "in process", + "filepath" : "pre_aaf_transcribe_hook.py" + }, + { + "OTIO_SCHEMA" : "HookScript.1", + "name" : "post_aaf_transcribe_hook", + "execution_scope" : "in process", + "filepath" : "post_aaf_transcribe_hook.py" + } + ], + "hooks" : { + "otio_aaf_pre_transcribe": ["pre_aaf_transcribe_hook"], + "otio_aaf_post_transcribe": ["post_aaf_transcribe_hook"] + } +} \ No newline at end of file diff --git a/tests/hooks_plugin_example/post_aaf_transcribe_hook.py b/tests/hooks_plugin_example/post_aaf_transcribe_hook.py new file mode 100644 index 0000000..9e6ae60 --- /dev/null +++ b/tests/hooks_plugin_example/post_aaf_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_transcribe_hook.py b/tests/hooks_plugin_example/pre_aaf_transcribe_hook.py new file mode 100644 index 0000000..5e14509 --- /dev/null +++ b/tests/hooks_plugin_example/pre_aaf_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/test_aaf_adapter.py b/tests/test_aaf_adapter.py index a2ec59f..d6016c4 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 @@ -226,7 +229,8 @@ Transition, Timecode, OperationGroup, - Sequence) + Sequence, + EdgeCode) from aaf2.mobs import MasterMob, SourceMob from aaf2.misc import VaryingValue could_import_aaf = True @@ -1630,7 +1634,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') @@ -1843,33 +1864,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): @@ -1892,6 +1920,182 @@ 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_transcribe_hook", "pre_aaf_transcribe_hook"]: + self.assertIn(hook_script, otio.plugins.plugin_info_map()[ + "hook_scripts"]) + + for hook_name in [hooks.HOOK_PRE_TRANSCRIBE, hooks.HOOK_POST_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=144, + 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(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(suffix='.aaf') + + otio.adapters.write_to_file( + tl, + filepath=tmp_aaf_path, + embed_essence=True, + use_empty_mob_ids=True, + ) + + self.assertTrue(Path(tmp_aaf_path).is_file()) + + def test_transcribe_embed_wav_audio(self): + """Tests simple WAV audio essence import.""" + range = otio.opentime.TimeRange( + start_time=otio.opentime.RationalTime( + value=1, + 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(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(suffix='.aaf') + + otio.adapters.write_to_file( + tl, + filepath=tmp_aaf_path, + embed_essence=True, + use_empty_mob_ids=True, + ) + + self.assertTrue(Path(tmp_aaf_path).is_file()) + + 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=1, + rate=24.0 + ), + duration=otio.opentime.RationalTime( + value=144, + 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(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(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=1, + rate=24.0 + ), + duration=otio.opentime.RationalTime( + value=144, + 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(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(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) + class SimplifyTests(unittest.TestCase): def test_aaf_simplify(self): @@ -2021,6 +2225,5 @@ def test_simplify_stack_track_clip(self): for i in simple_tl.tracks: self.assertNotEqual(type(i), otio.schema.Clip) - if __name__ == '__main__': unittest.main()