Skip to content

Commit

Permalink
Merge pull request #9 from markreidvfx/resolve_compat_v2
Browse files Browse the repository at this point in the history
Fix DaVinci Resolve compatibility and export NetworkLocators
  • Loading branch information
markreidvfx authored Apr 3, 2024
2 parents fd8f9b3 + da7ba7f commit e784e3e
Show file tree
Hide file tree
Showing 3 changed files with 193 additions and 10 deletions.
92 changes: 86 additions & 6 deletions src/otio_aaf_adapter/adapters/aaf_adapter/aaf_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand All @@ -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."""

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down
12 changes: 11 additions & 1 deletion src/otio_aaf_adapter/adapters/advanced_authoring_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
99 changes: 96 additions & 3 deletions tests/test_aaf_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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')
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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
Expand All @@ -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

Expand All @@ -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)]
Expand Down

0 comments on commit e784e3e

Please sign in to comment.