Skip to content

Commit b96e95d

Browse files
authored
AAF Adapter: support for reading markers (#1019)
1 parent ab0fa5c commit b96e95d

File tree

4 files changed

+339
-18
lines changed

4 files changed

+339
-18
lines changed

contrib/opentimelineio_contrib/adapters/advanced_authoring_format.py

Lines changed: 257 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,19 @@
2727
Depending on if/where PyAAF is installed, you may need to set this env var:
2828
OTIO_AAF_PYTHON_LIB - should point at the PyAAF module.
2929
"""
30-
30+
import colorsys
31+
import copy
32+
import numbers
3133
import os
3234
import sys
33-
import numbers
34-
import copy
35+
36+
try:
37+
# Python 2
38+
text_type = unicode
39+
except NameError:
40+
# Python 3
41+
text_type = str
42+
3543
try:
3644
# Python 3.3+
3745
import collections.abc as collections_abc
@@ -103,9 +111,10 @@ def _get_class_name(item):
103111
def _transcribe_property(prop):
104112
# XXX: The unicode type doesn't exist in Python 3 (all strings are unicode)
105113
# so we have to use type(u"") which works in both Python 2 and 3.
106-
if isinstance(prop, (str, type(u""), numbers.Integral, float)):
114+
if isinstance(prop, (str, type(u""), numbers.Integral, float, dict)):
107115
return prop
108-
116+
elif isinstance(prop, set):
117+
return list(prop)
109118
elif isinstance(prop, list):
110119
result = {}
111120
for child in prop:
@@ -115,8 +124,7 @@ def _transcribe_property(prop):
115124
# @TODO: There may be more properties that we might want also.
116125
# If you want to see what is being skipped, turn on debug.
117126
if debug:
118-
debug_message = \
119-
"Skipping unrecognized property: {} of parent {}"
127+
debug_message = "Skipping unrecognized property: {} of parent {}"
120128
print(debug_message.format(child, prop))
121129
return result
122130
elif hasattr(prop, "properties"):
@@ -128,6 +136,110 @@ def _transcribe_property(prop):
128136
return str(prop)
129137

130138

139+
def _otio_color_from_hue(hue):
140+
"""Return an OTIO marker color, based on hue in range of [0.0, 1.0].
141+
142+
Args:
143+
hue (float): marker color hue value
144+
145+
Returns:
146+
otio.schema.MarkerColor: converted / estimated marker color
147+
148+
"""
149+
if hue <= 0.04 or hue > 0.93:
150+
return otio.schema.MarkerColor.RED
151+
if hue <= 0.13:
152+
return otio.schema.MarkerColor.ORANGE
153+
if hue <= 0.2:
154+
return otio.schema.MarkerColor.YELLOW
155+
if hue <= 0.43:
156+
return otio.schema.MarkerColor.GREEN
157+
if hue <= 0.52:
158+
return otio.schema.MarkerColor.CYAN
159+
if hue <= 0.74:
160+
return otio.schema.MarkerColor.BLUE
161+
if hue <= 0.82:
162+
return otio.schema.MarkerColor.PURPLE
163+
return otio.schema.MarkerColor.MAGENTA
164+
165+
166+
def _marker_color_from_string(color):
167+
"""Tries to derive a valid marker color from a string.
168+
169+
Args:
170+
color (str): color name (e.g. "Yellow")
171+
172+
Returns:
173+
otio.schema.MarkerColor: matching color or `None`
174+
"""
175+
if not color:
176+
return
177+
178+
return getattr(otio.schema.MarkerColor, color.upper(), None)
179+
180+
181+
def _convert_rgb_to_marker_color(rgb_dict):
182+
"""Returns a matching OTIO marker color for a given AAF color string.
183+
184+
Adapted from `get_nearest_otio_color()` in the `xges.py` adapter.
185+
186+
Args:
187+
rgb_dict (dict): marker color as dict,
188+
e.g. `"{'red': 41471, 'green': 12134, 'blue': 6564}"`
189+
190+
Returns:
191+
otio.schema.MarkerColor: converted / estimated marker color
192+
193+
"""
194+
195+
float_colors = {
196+
(1.0, 0.0, 0.0): otio.schema.MarkerColor.RED,
197+
(0.0, 1.0, 0.0): otio.schema.MarkerColor.GREEN,
198+
(0.0, 0.0, 1.0): otio.schema.MarkerColor.BLUE,
199+
(0.0, 0.0, 0.0): otio.schema.MarkerColor.BLACK,
200+
(1.0, 1.0, 1.0): otio.schema.MarkerColor.WHITE,
201+
}
202+
203+
# convert from UInt to float
204+
red = float(rgb_dict["red"]) / 65535.0
205+
green = float(rgb_dict["green"]) / 65535.0
206+
blue = float(rgb_dict["blue"]) / 65535.0
207+
rgb_float = (red, green, blue)
208+
209+
# check for exact match
210+
marker_color = float_colors.get(rgb_float)
211+
if marker_color:
212+
return marker_color
213+
214+
# try to get an approxiate match based on hue
215+
hue, lightness, saturation = colorsys.rgb_to_hls(red, green, blue)
216+
nearest = None
217+
if saturation < 0.2:
218+
if lightness > 0.65:
219+
nearest = otio.schema.MarkerColor.WHITE
220+
else:
221+
nearest = otio.schema.MarkerColor.BLACK
222+
if nearest is None:
223+
if lightness < 0.13:
224+
nearest = otio.schema.MarkerColor.BLACK
225+
if lightness > 0.9:
226+
nearest = otio.schema.MarkerColor.WHITE
227+
if nearest is None:
228+
nearest = _otio_color_from_hue(hue)
229+
if nearest == otio.schema.MarkerColor.RED and lightness > 0.53:
230+
nearest = otio.schema.MarkerColor.PINK
231+
if (
232+
nearest == otio.schema.MarkerColor.MAGENTA
233+
and hue < 0.89
234+
and lightness < 0.42
235+
):
236+
# some darker magentas look more like purple
237+
nearest = otio.schema.MarkerColor.PURPLE
238+
239+
# default to red color
240+
return nearest or otio.schema.MarkerColor.RED
241+
242+
131243
def _find_timecode_mobs(item):
132244
mobs = [item.mob]
133245

@@ -474,6 +586,17 @@ def _transcribe(item, parents, edit_rate, indent=0):
474586
_transcribe_log(msg, indent)
475587
result = otio.schema.Track()
476588

589+
# if parent is a sequence add SlotID / PhysicalTrackNumber to attach markers
590+
parent = parents[-1]
591+
if isinstance(parent, (aaf2.components.Sequence, aaf2.components.NestedScope)):
592+
timeline_slots = [
593+
p for p in parents if isinstance(p, aaf2.mobslots.TimelineMobSlot)
594+
]
595+
timeline_slot = timeline_slots[-1]
596+
if timeline_slot:
597+
metadata["PhysicalTrackNumber"] = list(parent.slots).index(item) + 1
598+
metadata["SlotID"] = int(timeline_slot["SlotID"].value)
599+
477600
for component in item.components:
478601
child = _transcribe(component, parents + [item], edit_rate, indent + 2)
479602
_add_child(result, child, component)
@@ -524,11 +647,51 @@ def _transcribe(item, parents, edit_rate, indent=0):
524647
)
525648

526649
elif isinstance(item, aaf2.components.DescriptiveMarker):
650+
event_mobs = [p for p in parents if isinstance(p, aaf2.mobslots.EventMobSlot)]
651+
if event_mobs:
652+
_transcribe_log(
653+
"Create marker for '{}'".format(_encoded_name(item)), indent
654+
)
527655

528-
# Markers come in on their own separate Track.
529-
# TODO: We should consolidate them onto the same track(s) as the clips
530-
# result = otio.schema.Marker()
531-
pass
656+
result = otio.schema.Marker()
657+
result.name = metadata["Comment"]
658+
659+
event_mob = event_mobs[-1]
660+
661+
metadata["AttachedSlotID"] = int(metadata["DescribedSlots"][0])
662+
metadata["AttachedPhysicalTrackNumber"] = int(
663+
event_mob["PhysicalTrackNumber"].value
664+
)
665+
666+
# determine marker color
667+
color = _marker_color_from_string(
668+
metadata.get("CommentMarkerAttributeList", {}).get("_ATN_CRM_COLOR")
669+
)
670+
if color is None:
671+
color = _convert_rgb_to_marker_color(
672+
metadata["CommentMarkerColor"]
673+
)
674+
result.color = color
675+
676+
position = metadata["Position"]
677+
678+
# Length can be None, but the property will always exist
679+
# so get('Length', 1) wouldn't help.
680+
length = metadata["Length"]
681+
if length is None:
682+
length = 1
683+
684+
result.marked_range = otio.opentime.TimeRange(
685+
start_time=otio.opentime.from_frames(position, edit_rate),
686+
duration=otio.opentime.from_frames(length, edit_rate),
687+
)
688+
else:
689+
_transcribe_log(
690+
"Cannot attach marker item '{}'. "
691+
"Missing event mob in hierarchy.".format(
692+
_encoded_name(item)
693+
)
694+
)
532695

533696
elif isinstance(item, aaf2.components.Selector):
534697
msg = "Transcribe selector for {}".format(_encoded_name(item))
@@ -986,6 +1149,72 @@ def _fix_transitions(thing):
9861149
_fix_transitions(child)
9871150

9881151

1152+
def _attach_markers(collection):
1153+
"""Search for markers on tracks and attach them to their corresponding item.
1154+
1155+
Marked ranges will also be transformed into the new parent space.
1156+
1157+
"""
1158+
# iterate all timeline objects
1159+
for timeline in collection.each_child(descended_from_type=otio.schema.Timeline):
1160+
tracks_map = {}
1161+
1162+
# build track mapping
1163+
for track in timeline.each_child(descended_from_type=otio.schema.Track):
1164+
metadata = track.metadata.get("AAF", {})
1165+
slot_id = metadata.get("SlotID")
1166+
track_number = metadata.get("PhysicalTrackNumber")
1167+
if slot_id is None or track_number is None:
1168+
continue
1169+
1170+
tracks_map[(int(slot_id), int(track_number))] = track
1171+
1172+
# iterate all tracks for their markers and attach them to the matching item
1173+
for current_track in timeline.each_child(descended_from_type=otio.schema.Track):
1174+
for marker in list(current_track.markers):
1175+
metadata = marker.metadata.get("AAF", {})
1176+
slot_id = metadata.get("AttachedSlotID")
1177+
track_number = metadata.get("AttachedPhysicalTrackNumber")
1178+
target_track = tracks_map.get((slot_id, track_number))
1179+
if target_track is None:
1180+
raise AAFAdapterError(
1181+
"Marker '{}' cannot be attached to an item. SlotID: '{}', "
1182+
"PhysicalTrackNumber: '{}'".format(
1183+
marker.name, slot_id, track_number
1184+
)
1185+
)
1186+
1187+
# remove marker from current parent track
1188+
current_track.markers.remove(marker)
1189+
1190+
# determine new item to attach the marker to
1191+
target_item = target_track.child_at_time(marker.marked_range.start_time)
1192+
if target_item is None:
1193+
target_item = target_track
1194+
1195+
# attach marker to target item
1196+
target_item.markers.append(marker)
1197+
1198+
# transform marked range into new item range
1199+
marked_start_local = current_track.transformed_time(
1200+
marker.marked_range.start_time, target_item
1201+
)
1202+
1203+
marker.marked_range = otio.opentime.TimeRange(
1204+
start_time=marked_start_local, duration=marker.marked_range.duration
1205+
)
1206+
1207+
_transcribe_log(
1208+
"Marker: '{}' (time: {}), attached to item: '{}'".format(
1209+
marker.name,
1210+
marker.marked_range.start_time.value,
1211+
target_item.name,
1212+
)
1213+
)
1214+
1215+
return collection
1216+
1217+
9891218
def _simplify(thing):
9901219
# If the passed in is an empty dictionary or None, nothing to do.
9911220
# Without this check it would still return thing, but this way we avoid
@@ -1167,8 +1396,20 @@ def _contains_something_valuable(thing):
11671396
return True
11681397

11691398

1170-
def read_from_file(filepath, simplify=True, transcribe_log=False):
1399+
def read_from_file(filepath, simplify=True, transcribe_log=False, attach_markers=True):
1400+
"""Reads AAF content from `filepath` and outputs an OTIO timeline object.
1401+
1402+
Args:
1403+
filepath (str): AAF filepath
1404+
simplify (bool, optional): simplify timeline structure by stripping empty items
1405+
transcribe_log (bool, optional): log activity as items are getting transcribed
1406+
attach_markers (bool, optional): attaches markers to their appropriate items
1407+
like clip, gap. etc on the track
11711408
1409+
Returns:
1410+
otio.schema.Timeline
1411+
1412+
"""
11721413
# 'activate' transcribe logging if adapter argument is provided.
11731414
# Note that a global 'switch' is used in order to avoid
11741415
# passing another argument around in the _transcribe() method.
@@ -1190,8 +1431,11 @@ def read_from_file(filepath, simplify=True, transcribe_log=False):
11901431
# Transcribe just the top-level mobs
11911432
result = _transcribe(top, parents=list(), edit_rate=None)
11921433

1193-
# AAF is typically more deeply nested than OTIO.
1434+
# Attach marker to the appropriate clip, gap etc.
1435+
if attach_markers:
1436+
result = _attach_markers(result)
11941437

1438+
# AAF is typically more deeply nested than OTIO.
11951439
# Lets try to simplify the structure by collapsing or removing
11961440
# unnecessary stuff.
11971441
if simplify:
900 KB
Binary file not shown.

0 commit comments

Comments
 (0)