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 520d38f..45cd574 100644 --- a/src/otio_aaf_adapter/adapters/aaf_adapter/aaf_writer.py +++ b/src/otio_aaf_adapter/adapters/aaf_adapter/aaf_writer.py @@ -58,6 +58,27 @@ def _is_considered_gap(thing): return False +def _nearest_timecode(rate): + supported_rates = (24.0, + 25.0, + 30.0, + 60.0) + nearest_rate = 0.0 + min_diff = float("inf") + for valid_rate in supported_rates: + if valid_rate == rate: + return rate + + diff = abs(rate - valid_rate) + if diff >= min_diff: + continue + + min_diff = diff + nearest_rate = valid_rate + + return nearest_rate + + class AAFAdapterError(otio.exceptions.OTIOError): pass @@ -142,6 +163,13 @@ def _unique_tapemob(self, otio_clip): tape_timecode_slot.segment.length = int(timecode_length) self.aaf_file.content.mobs.append(tapemob) self._unique_tapemobs[mob_id] = tapemob + + media = otio_clip.media_reference + if isinstance(media, otio.schema.ExternalReference) and media.target_url: + locator = self.aaf_file.create.NetworkLocator() + locator['URLString'].value = media.target_url + tapemob.descriptor["Locator"].append(locator) + return tapemob def track_transcriber(self, otio_track): @@ -155,6 +183,34 @@ def track_transcriber(self, otio_track): f"Unsupported track kind: {otio_track.kind}") return transcriber + def add_timecode(self, input_otio, default_edit_rate): + """ + Add CompositionMob level timecode track base on global_start_time + if available, otherwise start is set to 0. + """ + if input_otio.global_start_time: + edit_rate = input_otio.global_start_time.rate + start = int(input_otio.global_start_time.value) + else: + edit_rate = default_edit_rate + start = 0 + + slot = self.compositionmob.create_timeline_slot(edit_rate) + slot.name = "TC" + + # indicated that this is the primary timecode track + slot['PhysicalTrackNumber'].value = 1 + + # timecode.start is in edit_rate units NOT timecode fps + # timecode.fps is only really a hint for a NLE displays on + # how to display the start frame index to the user. + # currently only selects basic non drop frame rates + timecode = self.aaf_file.create.Timecode() + timecode.fps = int(_nearest_timecode(edit_rate)) + timecode.drop = False + timecode.start = start + slot.segment = timecode + def _transcribe_user_comments(self, otio_item, target_mob): """Transcribes user comments on `otio_item` onto `target_mob` in AAF.""" @@ -230,12 +286,13 @@ def _from_media_reference_metadata(clip): def _from_aaf_file(clip): """ Get the MobID from the AAF file itself.""" mob_id = None - target_url = clip.media_reference.target_url - if os.path.isfile(target_url) and target_url.endswith("aaf"): - with aaf2.open(clip.media_reference.target_url) as aaf_file: - mastermobs = list(aaf_file.content.mastermobs()) - if len(mastermobs) == 1: - mob_id = mastermobs[0].mob_id + if isinstance(clip.media_reference, otio.schema.ExternalReference): + target_url = clip.media_reference.target_url + if os.path.isfile(target_url) and target_url.endswith("aaf"): + with aaf2.open(clip.media_reference.target_url) as aaf_file: + mastermobs = list(aaf_file.content.mastermobs()) + if len(mastermobs) == 1: + mob_id = mastermobs[0].mob_id return mob_id def _generate_empty_mobid(clip): @@ -377,6 +434,11 @@ def default_descriptor(self, otio_clip): def _transition_parameters(self): pass + def aaf_network_locator(self, otio_external_ref): + locator = self.aaf_file.create.NetworkLocator() + locator['URLString'].value = otio_external_ref.target_url + return locator + def aaf_filler(self, otio_gap): """Convert an otio Gap into an aaf Filler""" length = int(otio_gap.visible_range().duration.value) @@ -484,6 +546,7 @@ def aaf_transition(self, otio_transition): def aaf_sequence(self, otio_track): """Convert an otio Track into an aaf Sequence""" sequence = self.aaf_file.create.Sequence(media_kind=self.media_kind) + sequence.components.value = [] length = 0 for nested_otio_child in otio_track: result = self.transcribe(nested_otio_child) @@ -606,6 +669,7 @@ def _create_timeline_mobslot(self): timeline_mobslot = self.compositionmob.create_timeline_slot( edit_rate=self.edit_rate) sequence = self.aaf_file.create.Sequence(media_kind=self.media_kind) + sequence.components.value = [] timeline_mobslot.segment = sequence return timeline_mobslot, sequence @@ -622,6 +686,16 @@ def default_descriptor(self, otio_clip): descriptor["VideoLineMap"].value = [42, 0] descriptor["SampleRate"].value = 24 descriptor["Length"].value = 1 + + media = otio_clip.media_reference + if isinstance(media, otio.schema.ExternalReference): + if media.target_url: + locator = self.aaf_network_locator(media) + descriptor["Locator"].append(locator) + if media.available_range: + descriptor['SampleRate'].value = media.available_range.duration.rate + descriptor["Length"].value = int(media.available_range.duration.value) + return descriptor def _transition_parameters(self): @@ -731,6 +805,7 @@ def _create_timeline_mobslot(self): timeline_mobslot.segment = opgroup # Sequence sequence = self.aaf_file.create.Sequence(media_kind=self.media_kind) + sequence.components.value = [] sequence.length = total_length opgroup.segments.append(sequence) return timeline_mobslot, sequence @@ -746,6 +821,11 @@ def default_descriptor(self, otio_clip): descriptor["Length"].value = int( otio_clip.media_reference.available_range.duration.value ) + + if isinstance(otio_clip.media_reference, otio.schema.ExternalReference): + locator = self.aaf_network_locator(otio_clip.media_reference) + descriptor["Locator"].append(locator) + return descriptor def _transition_parameters(self): diff --git a/src/otio_aaf_adapter/adapters/advanced_authoring_format.py b/src/otio_aaf_adapter/adapters/advanced_authoring_format.py index ccc08c4..4d9bfc6 100644 --- a/src/otio_aaf_adapter/adapters/advanced_authoring_format.py +++ b/src/otio_aaf_adapter/adapters/advanced_authoring_format.py @@ -223,6 +223,8 @@ def _convert_rgb_to_marker_color(rgb_dict): (0.0, 0.0, 0.0): otio.schema.MarkerColor.BLACK, (1.0, 1.0, 1.0): otio.schema.MarkerColor.WHITE, } + if not rgb_dict: + return otio.schema.MarkerColor.RED # convert from UInt to float red = float(rgb_dict["red"]) / 65535.0 @@ -695,7 +697,7 @@ def _transcribe(item, parents, edit_rate, indent=0): ) if color is None: color = _convert_rgb_to_marker_color( - metadata["CommentMarkerColor"] + metadata.get("CommentMarkerColor") ) result.color = color @@ -1650,14 +1652,22 @@ def write_to_file(input_otio, filepath, **kwargs): raise otio.exceptions.NotSupportedError( "Currently only supporting top level Timeline") + default_edit_rate = None for otio_track in timeline.tracks: # Ensure track must have clip to get the edit_rate if len(otio_track) == 0: continue transcriber = otio2aaf.track_transcriber(otio_track) + if not default_edit_rate: + default_edit_rate = transcriber.edit_rate for otio_child in otio_track: result = transcriber.transcribe(otio_child) if result: transcriber.sequence.components.append(result) + + # Always add a timecode track to the main composition mob. + # This is required for compatibility with DaVinci Resolve. + if default_edit_rate or input_otio.global_start_time: + otio2aaf.add_timecode(input_otio, default_edit_rate) diff --git a/tests/test_aaf_adapter.py b/tests/test_aaf_adapter.py index c1bcc72..4d3bb3b 100644 --- a/tests/test_aaf_adapter.py +++ b/tests/test_aaf_adapter.py @@ -238,6 +238,7 @@ Sequence) from aaf2.mobs import MasterMob, SourceMob from aaf2.misc import VaryingValue + from aaf2.mobid import MobID could_import_aaf = True except (ImportError): could_import_aaf = False @@ -1795,6 +1796,42 @@ def test_aaf_writer_nesting(self): def test_aaf_writer_nested_stack(self): self._verify_aaf(NESTED_STACK_EXAMPLE_PATH) + def test_aaf_writer_external_reference(self): + target_url = "file:///C%3A/Avid%20MediaFiles/MXF/1/7003_Vi48896FA0V.mxf" + + mob_id = MobID(int=10) + metadata = {"AAF": {"SourceID": str(mob_id)}} + + tl = otio.schema.Timeline() + cl = otio.schema.Clip("clip0", metadata=metadata) + + cl.source_range = otio.opentime.TimeRange( + otio.opentime.RationalTime(0, 24), + otio.opentime.RationalTime(100, 24), + ) + tl.tracks.append(otio.schema.Track(kind='Video')) + tl.tracks[0].append(cl) + cl.media_reference = otio.schema.ExternalReference(target_url, + cl.source_range) + + fd, tmp_aaf_path = tempfile.mkstemp(suffix='.aaf') + otio.adapters.write_to_file(tl, tmp_aaf_path) + + self._verify_aaf(tmp_aaf_path) + + with aaf2.open(tmp_aaf_path) as dest: + mastermob = dest.content.mobs.get(mob_id, None) + self.assertNotEqual(mastermob, None) + self.assertEqual(cl.name, mastermob.name) + self.assertEqual(mob_id, mastermob.mob_id) + self.assertEqual(len(mastermob.slots), 1) + source_clip = mastermob.slots[0].segment + self.assertEqual(source_clip.media_kind, "Picture") + filemob = source_clip.mob + self.assertEqual(len(filemob.descriptor['Locator']), 1) + locator = filemob.descriptor['Locator'].value[0] + self.assertEqual(locator['URLString'].value, target_url) + def test_generator_reference(self): tl = otio.schema.Timeline() cl = otio.schema.Clip() @@ -1869,6 +1906,37 @@ def test_aaf_writer_user_comments(self): self.assertEqual(dict(master_mob.comments.items()), expected_comments) self.assertEqual(dict(comp_mob.comments.items()), expected_comments) + def test_aaf_writer_global_start_time(self): + for tc, rate in [("01:00:00:00", 23.97), + ("01:00:00:00", 24), + ("01:00:00:00", 25), + ("01:00:00:00", 29.97), + ("01:00:00:00", 30), + ("01:00:00:00", 59.94), + ("01:00:00:00", 60)]: + + otio_timeline = otio.schema.Timeline() + otio_timeline.global_start_time = otio.opentime.from_timecode(tc, rate) + fd, tmp_aaf_path = tempfile.mkstemp(suffix='.aaf') + otio.adapters.write_to_file(otio_timeline, tmp_aaf_path) + + self._verify_aaf(tmp_aaf_path) + + for frame, rate in [(100, 12.97), + (100, 3.0), + (100, 26.5), + (100, 31), + (100, 45), + (100, 120.0), + (100, 90.0)]: + + otio_timeline = otio.schema.Timeline() + otio_timeline.global_start_time = otio.opentime.RationalTime(frame, rate) + fd, tmp_aaf_path = tempfile.mkstemp(suffix='.aaf') + otio.adapters.write_to_file(otio_timeline, tmp_aaf_path) + + self._verify_aaf(tmp_aaf_path) + def _verify_aaf(self, aaf_path): otio_timeline = otio.adapters.read_from_file(aaf_path, simplify=True) fd, tmp_aaf_path = tempfile.mkstemp(suffix='.aaf') @@ -1884,7 +1952,9 @@ def _verify_aaf(self, aaf_path): compositionmobs = list(dest.content.compositionmobs()) self.assertEqual(1, len(compositionmobs)) compositionmob = compositionmobs[0] - self.assertEqual(len(otio_timeline.tracks), len(compositionmob.slots)) + + # + 1 is for the timecode track + self.assertEqual(len(otio_timeline.tracks) + 1, len(compositionmob.slots)) for otio_track, aaf_timeline_mobslot in zip(otio_timeline.tracks, compositionmob.slots): @@ -1926,7 +1996,8 @@ def _verify_aaf(self, aaf_path): type_mapping[type(otio_child)]) if isinstance(aaf_component, SourceClip): - self._verify_compositionmob_sourceclip_structure(aaf_component) + self._verify_compositionmob_sourceclip_structure(otio_child, + aaf_component) if isinstance(aaf_component, aaf2.components.OperationGroup): nested_aaf_segments = aaf_component.segments @@ -1937,6 +2008,16 @@ def _verify_aaf(self, aaf_path): else: self._is_otio_aaf_same(otio_child, aaf_component) + # check the global_start_time and timecode slot + for slot in compositionmob.slots: + if isinstance(slot.segment, Timecode): + self.assertEqual(otio_timeline.global_start_time.rate, + float(slot.edit_rate)) + self.assertEqual(otio_timeline.global_start_time.value, + slot.segment.start) + self.assertTrue(slot.segment.fps in [24, 25, 30, 60]) + self.assertTrue(slot['PhysicalTrackNumber'].value == 1) + # Inspect the OTIO -> AAF -> OTIO file roundtripped_otio = otio.adapters.read_from_file(tmp_aaf_path, simplify=True) @@ -1946,7 +2027,7 @@ def _verify_aaf(self, aaf_path): self.assertEqual(otio_timeline.duration().rate, roundtripped_otio.duration().rate) - def _verify_compositionmob_sourceclip_structure(self, compmob_clip): + def _verify_compositionmob_sourceclip_structure(self, otio_child, compmob_clip): self.assertTrue(isinstance(compmob_clip, SourceClip)) self.assertTrue(isinstance(compmob_clip.mob, MasterMob)) mastermob = compmob_clip.mob @@ -1956,6 +2037,12 @@ def _verify_compositionmob_sourceclip_structure(self, compmob_clip): self.assertTrue(isinstance(mastermob_clip.mob, SourceMob)) filemob = mastermob_clip.mob + if (otio_child.media_reference): + self.assertEqual(len(filemob.descriptor['Locator']), 1) + locator = filemob.descriptor['Locator'].value[0] + self.assertEqual(locator['URLString'].value, + otio_child.media_reference.target_url) + self.assertEqual(1, len(filemob.slots)) filemob_clip = filemob.slots[0].segment @@ -1964,6 +2051,12 @@ def _verify_compositionmob_sourceclip_structure(self, compmob_clip): tapemob = filemob_clip.mob self.assertTrue(len(tapemob.slots) >= 2) + if (otio_child.media_reference): + self.assertEqual(len(tapemob.descriptor['Locator']), 1) + locator = tapemob.descriptor['Locator'].value[0] + self.assertEqual(locator['URLString'].value, + otio_child.media_reference.target_url) + timecode_slots = [tape_slot for tape_slot in tapemob.slots if isinstance(tape_slot.segment, Timecode)]