Skip to content

Commit e784e3e

Browse files
authored
Merge pull request #9 from markreidvfx/resolve_compat_v2
Fix DaVinci Resolve compatibility and export NetworkLocators
2 parents fd8f9b3 + da7ba7f commit e784e3e

File tree

3 files changed

+193
-10
lines changed

3 files changed

+193
-10
lines changed

src/otio_aaf_adapter/adapters/aaf_adapter/aaf_writer.py

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,27 @@ def _is_considered_gap(thing):
5858
return False
5959

6060

61+
def _nearest_timecode(rate):
62+
supported_rates = (24.0,
63+
25.0,
64+
30.0,
65+
60.0)
66+
nearest_rate = 0.0
67+
min_diff = float("inf")
68+
for valid_rate in supported_rates:
69+
if valid_rate == rate:
70+
return rate
71+
72+
diff = abs(rate - valid_rate)
73+
if diff >= min_diff:
74+
continue
75+
76+
min_diff = diff
77+
nearest_rate = valid_rate
78+
79+
return nearest_rate
80+
81+
6182
class AAFAdapterError(otio.exceptions.OTIOError):
6283
pass
6384

@@ -142,6 +163,13 @@ def _unique_tapemob(self, otio_clip):
142163
tape_timecode_slot.segment.length = int(timecode_length)
143164
self.aaf_file.content.mobs.append(tapemob)
144165
self._unique_tapemobs[mob_id] = tapemob
166+
167+
media = otio_clip.media_reference
168+
if isinstance(media, otio.schema.ExternalReference) and media.target_url:
169+
locator = self.aaf_file.create.NetworkLocator()
170+
locator['URLString'].value = media.target_url
171+
tapemob.descriptor["Locator"].append(locator)
172+
145173
return tapemob
146174

147175
def track_transcriber(self, otio_track):
@@ -155,6 +183,34 @@ def track_transcriber(self, otio_track):
155183
f"Unsupported track kind: {otio_track.kind}")
156184
return transcriber
157185

186+
def add_timecode(self, input_otio, default_edit_rate):
187+
"""
188+
Add CompositionMob level timecode track base on global_start_time
189+
if available, otherwise start is set to 0.
190+
"""
191+
if input_otio.global_start_time:
192+
edit_rate = input_otio.global_start_time.rate
193+
start = int(input_otio.global_start_time.value)
194+
else:
195+
edit_rate = default_edit_rate
196+
start = 0
197+
198+
slot = self.compositionmob.create_timeline_slot(edit_rate)
199+
slot.name = "TC"
200+
201+
# indicated that this is the primary timecode track
202+
slot['PhysicalTrackNumber'].value = 1
203+
204+
# timecode.start is in edit_rate units NOT timecode fps
205+
# timecode.fps is only really a hint for a NLE displays on
206+
# how to display the start frame index to the user.
207+
# currently only selects basic non drop frame rates
208+
timecode = self.aaf_file.create.Timecode()
209+
timecode.fps = int(_nearest_timecode(edit_rate))
210+
timecode.drop = False
211+
timecode.start = start
212+
slot.segment = timecode
213+
158214
def _transcribe_user_comments(self, otio_item, target_mob):
159215
"""Transcribes user comments on `otio_item` onto `target_mob` in AAF."""
160216

@@ -230,12 +286,13 @@ def _from_media_reference_metadata(clip):
230286
def _from_aaf_file(clip):
231287
""" Get the MobID from the AAF file itself."""
232288
mob_id = None
233-
target_url = clip.media_reference.target_url
234-
if os.path.isfile(target_url) and target_url.endswith("aaf"):
235-
with aaf2.open(clip.media_reference.target_url) as aaf_file:
236-
mastermobs = list(aaf_file.content.mastermobs())
237-
if len(mastermobs) == 1:
238-
mob_id = mastermobs[0].mob_id
289+
if isinstance(clip.media_reference, otio.schema.ExternalReference):
290+
target_url = clip.media_reference.target_url
291+
if os.path.isfile(target_url) and target_url.endswith("aaf"):
292+
with aaf2.open(clip.media_reference.target_url) as aaf_file:
293+
mastermobs = list(aaf_file.content.mastermobs())
294+
if len(mastermobs) == 1:
295+
mob_id = mastermobs[0].mob_id
239296
return mob_id
240297

241298
def _generate_empty_mobid(clip):
@@ -377,6 +434,11 @@ def default_descriptor(self, otio_clip):
377434
def _transition_parameters(self):
378435
pass
379436

437+
def aaf_network_locator(self, otio_external_ref):
438+
locator = self.aaf_file.create.NetworkLocator()
439+
locator['URLString'].value = otio_external_ref.target_url
440+
return locator
441+
380442
def aaf_filler(self, otio_gap):
381443
"""Convert an otio Gap into an aaf Filler"""
382444
length = int(otio_gap.visible_range().duration.value)
@@ -484,6 +546,7 @@ def aaf_transition(self, otio_transition):
484546
def aaf_sequence(self, otio_track):
485547
"""Convert an otio Track into an aaf Sequence"""
486548
sequence = self.aaf_file.create.Sequence(media_kind=self.media_kind)
549+
sequence.components.value = []
487550
length = 0
488551
for nested_otio_child in otio_track:
489552
result = self.transcribe(nested_otio_child)
@@ -606,6 +669,7 @@ def _create_timeline_mobslot(self):
606669
timeline_mobslot = self.compositionmob.create_timeline_slot(
607670
edit_rate=self.edit_rate)
608671
sequence = self.aaf_file.create.Sequence(media_kind=self.media_kind)
672+
sequence.components.value = []
609673
timeline_mobslot.segment = sequence
610674
return timeline_mobslot, sequence
611675

@@ -622,6 +686,16 @@ def default_descriptor(self, otio_clip):
622686
descriptor["VideoLineMap"].value = [42, 0]
623687
descriptor["SampleRate"].value = 24
624688
descriptor["Length"].value = 1
689+
690+
media = otio_clip.media_reference
691+
if isinstance(media, otio.schema.ExternalReference):
692+
if media.target_url:
693+
locator = self.aaf_network_locator(media)
694+
descriptor["Locator"].append(locator)
695+
if media.available_range:
696+
descriptor['SampleRate'].value = media.available_range.duration.rate
697+
descriptor["Length"].value = int(media.available_range.duration.value)
698+
625699
return descriptor
626700

627701
def _transition_parameters(self):
@@ -731,6 +805,7 @@ def _create_timeline_mobslot(self):
731805
timeline_mobslot.segment = opgroup
732806
# Sequence
733807
sequence = self.aaf_file.create.Sequence(media_kind=self.media_kind)
808+
sequence.components.value = []
734809
sequence.length = total_length
735810
opgroup.segments.append(sequence)
736811
return timeline_mobslot, sequence
@@ -746,6 +821,11 @@ def default_descriptor(self, otio_clip):
746821
descriptor["Length"].value = int(
747822
otio_clip.media_reference.available_range.duration.value
748823
)
824+
825+
if isinstance(otio_clip.media_reference, otio.schema.ExternalReference):
826+
locator = self.aaf_network_locator(otio_clip.media_reference)
827+
descriptor["Locator"].append(locator)
828+
749829
return descriptor
750830

751831
def _transition_parameters(self):

src/otio_aaf_adapter/adapters/advanced_authoring_format.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,8 @@ def _convert_rgb_to_marker_color(rgb_dict):
223223
(0.0, 0.0, 0.0): otio.schema.MarkerColor.BLACK,
224224
(1.0, 1.0, 1.0): otio.schema.MarkerColor.WHITE,
225225
}
226+
if not rgb_dict:
227+
return otio.schema.MarkerColor.RED
226228

227229
# convert from UInt to float
228230
red = float(rgb_dict["red"]) / 65535.0
@@ -695,7 +697,7 @@ def _transcribe(item, parents, edit_rate, indent=0):
695697
)
696698
if color is None:
697699
color = _convert_rgb_to_marker_color(
698-
metadata["CommentMarkerColor"]
700+
metadata.get("CommentMarkerColor")
699701
)
700702
result.color = color
701703

@@ -1650,14 +1652,22 @@ def write_to_file(input_otio, filepath, **kwargs):
16501652
raise otio.exceptions.NotSupportedError(
16511653
"Currently only supporting top level Timeline")
16521654

1655+
default_edit_rate = None
16531656
for otio_track in timeline.tracks:
16541657
# Ensure track must have clip to get the edit_rate
16551658
if len(otio_track) == 0:
16561659
continue
16571660

16581661
transcriber = otio2aaf.track_transcriber(otio_track)
1662+
if not default_edit_rate:
1663+
default_edit_rate = transcriber.edit_rate
16591664

16601665
for otio_child in otio_track:
16611666
result = transcriber.transcribe(otio_child)
16621667
if result:
16631668
transcriber.sequence.components.append(result)
1669+
1670+
# Always add a timecode track to the main composition mob.
1671+
# This is required for compatibility with DaVinci Resolve.
1672+
if default_edit_rate or input_otio.global_start_time:
1673+
otio2aaf.add_timecode(input_otio, default_edit_rate)

tests/test_aaf_adapter.py

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@
238238
Sequence)
239239
from aaf2.mobs import MasterMob, SourceMob
240240
from aaf2.misc import VaryingValue
241+
from aaf2.mobid import MobID
241242
could_import_aaf = True
242243
except (ImportError):
243244
could_import_aaf = False
@@ -1795,6 +1796,42 @@ def test_aaf_writer_nesting(self):
17951796
def test_aaf_writer_nested_stack(self):
17961797
self._verify_aaf(NESTED_STACK_EXAMPLE_PATH)
17971798

1799+
def test_aaf_writer_external_reference(self):
1800+
target_url = "file:///C%3A/Avid%20MediaFiles/MXF/1/7003_Vi48896FA0V.mxf"
1801+
1802+
mob_id = MobID(int=10)
1803+
metadata = {"AAF": {"SourceID": str(mob_id)}}
1804+
1805+
tl = otio.schema.Timeline()
1806+
cl = otio.schema.Clip("clip0", metadata=metadata)
1807+
1808+
cl.source_range = otio.opentime.TimeRange(
1809+
otio.opentime.RationalTime(0, 24),
1810+
otio.opentime.RationalTime(100, 24),
1811+
)
1812+
tl.tracks.append(otio.schema.Track(kind='Video'))
1813+
tl.tracks[0].append(cl)
1814+
cl.media_reference = otio.schema.ExternalReference(target_url,
1815+
cl.source_range)
1816+
1817+
fd, tmp_aaf_path = tempfile.mkstemp(suffix='.aaf')
1818+
otio.adapters.write_to_file(tl, tmp_aaf_path)
1819+
1820+
self._verify_aaf(tmp_aaf_path)
1821+
1822+
with aaf2.open(tmp_aaf_path) as dest:
1823+
mastermob = dest.content.mobs.get(mob_id, None)
1824+
self.assertNotEqual(mastermob, None)
1825+
self.assertEqual(cl.name, mastermob.name)
1826+
self.assertEqual(mob_id, mastermob.mob_id)
1827+
self.assertEqual(len(mastermob.slots), 1)
1828+
source_clip = mastermob.slots[0].segment
1829+
self.assertEqual(source_clip.media_kind, "Picture")
1830+
filemob = source_clip.mob
1831+
self.assertEqual(len(filemob.descriptor['Locator']), 1)
1832+
locator = filemob.descriptor['Locator'].value[0]
1833+
self.assertEqual(locator['URLString'].value, target_url)
1834+
17981835
def test_generator_reference(self):
17991836
tl = otio.schema.Timeline()
18001837
cl = otio.schema.Clip()
@@ -1869,6 +1906,37 @@ def test_aaf_writer_user_comments(self):
18691906
self.assertEqual(dict(master_mob.comments.items()), expected_comments)
18701907
self.assertEqual(dict(comp_mob.comments.items()), expected_comments)
18711908

1909+
def test_aaf_writer_global_start_time(self):
1910+
for tc, rate in [("01:00:00:00", 23.97),
1911+
("01:00:00:00", 24),
1912+
("01:00:00:00", 25),
1913+
("01:00:00:00", 29.97),
1914+
("01:00:00:00", 30),
1915+
("01:00:00:00", 59.94),
1916+
("01:00:00:00", 60)]:
1917+
1918+
otio_timeline = otio.schema.Timeline()
1919+
otio_timeline.global_start_time = otio.opentime.from_timecode(tc, rate)
1920+
fd, tmp_aaf_path = tempfile.mkstemp(suffix='.aaf')
1921+
otio.adapters.write_to_file(otio_timeline, tmp_aaf_path)
1922+
1923+
self._verify_aaf(tmp_aaf_path)
1924+
1925+
for frame, rate in [(100, 12.97),
1926+
(100, 3.0),
1927+
(100, 26.5),
1928+
(100, 31),
1929+
(100, 45),
1930+
(100, 120.0),
1931+
(100, 90.0)]:
1932+
1933+
otio_timeline = otio.schema.Timeline()
1934+
otio_timeline.global_start_time = otio.opentime.RationalTime(frame, rate)
1935+
fd, tmp_aaf_path = tempfile.mkstemp(suffix='.aaf')
1936+
otio.adapters.write_to_file(otio_timeline, tmp_aaf_path)
1937+
1938+
self._verify_aaf(tmp_aaf_path)
1939+
18721940
def _verify_aaf(self, aaf_path):
18731941
otio_timeline = otio.adapters.read_from_file(aaf_path, simplify=True)
18741942
fd, tmp_aaf_path = tempfile.mkstemp(suffix='.aaf')
@@ -1884,7 +1952,9 @@ def _verify_aaf(self, aaf_path):
18841952
compositionmobs = list(dest.content.compositionmobs())
18851953
self.assertEqual(1, len(compositionmobs))
18861954
compositionmob = compositionmobs[0]
1887-
self.assertEqual(len(otio_timeline.tracks), len(compositionmob.slots))
1955+
1956+
# + 1 is for the timecode track
1957+
self.assertEqual(len(otio_timeline.tracks) + 1, len(compositionmob.slots))
18881958

18891959
for otio_track, aaf_timeline_mobslot in zip(otio_timeline.tracks,
18901960
compositionmob.slots):
@@ -1926,7 +1996,8 @@ def _verify_aaf(self, aaf_path):
19261996
type_mapping[type(otio_child)])
19271997

19281998
if isinstance(aaf_component, SourceClip):
1929-
self._verify_compositionmob_sourceclip_structure(aaf_component)
1999+
self._verify_compositionmob_sourceclip_structure(otio_child,
2000+
aaf_component)
19302001

19312002
if isinstance(aaf_component, aaf2.components.OperationGroup):
19322003
nested_aaf_segments = aaf_component.segments
@@ -1937,6 +2008,16 @@ def _verify_aaf(self, aaf_path):
19372008
else:
19382009
self._is_otio_aaf_same(otio_child, aaf_component)
19392010

2011+
# check the global_start_time and timecode slot
2012+
for slot in compositionmob.slots:
2013+
if isinstance(slot.segment, Timecode):
2014+
self.assertEqual(otio_timeline.global_start_time.rate,
2015+
float(slot.edit_rate))
2016+
self.assertEqual(otio_timeline.global_start_time.value,
2017+
slot.segment.start)
2018+
self.assertTrue(slot.segment.fps in [24, 25, 30, 60])
2019+
self.assertTrue(slot['PhysicalTrackNumber'].value == 1)
2020+
19402021
# Inspect the OTIO -> AAF -> OTIO file
19412022
roundtripped_otio = otio.adapters.read_from_file(tmp_aaf_path, simplify=True)
19422023

@@ -1946,7 +2027,7 @@ def _verify_aaf(self, aaf_path):
19462027
self.assertEqual(otio_timeline.duration().rate,
19472028
roundtripped_otio.duration().rate)
19482029

1949-
def _verify_compositionmob_sourceclip_structure(self, compmob_clip):
2030+
def _verify_compositionmob_sourceclip_structure(self, otio_child, compmob_clip):
19502031
self.assertTrue(isinstance(compmob_clip, SourceClip))
19512032
self.assertTrue(isinstance(compmob_clip.mob, MasterMob))
19522033
mastermob = compmob_clip.mob
@@ -1956,6 +2037,12 @@ def _verify_compositionmob_sourceclip_structure(self, compmob_clip):
19562037
self.assertTrue(isinstance(mastermob_clip.mob, SourceMob))
19572038
filemob = mastermob_clip.mob
19582039

2040+
if (otio_child.media_reference):
2041+
self.assertEqual(len(filemob.descriptor['Locator']), 1)
2042+
locator = filemob.descriptor['Locator'].value[0]
2043+
self.assertEqual(locator['URLString'].value,
2044+
otio_child.media_reference.target_url)
2045+
19592046
self.assertEqual(1, len(filemob.slots))
19602047
filemob_clip = filemob.slots[0].segment
19612048

@@ -1964,6 +2051,12 @@ def _verify_compositionmob_sourceclip_structure(self, compmob_clip):
19642051
tapemob = filemob_clip.mob
19652052
self.assertTrue(len(tapemob.slots) >= 2)
19662053

2054+
if (otio_child.media_reference):
2055+
self.assertEqual(len(tapemob.descriptor['Locator']), 1)
2056+
locator = tapemob.descriptor['Locator'].value[0]
2057+
self.assertEqual(locator['URLString'].value,
2058+
otio_child.media_reference.target_url)
2059+
19672060
timecode_slots = [tape_slot for tape_slot in tapemob.slots
19682061
if isinstance(tape_slot.segment,
19692062
Timecode)]

0 commit comments

Comments
 (0)