Skip to content

Commit 8d18f5c

Browse files
committed
Writer: added pre- / post-transcribe hooks to facilitate media essence embedding
Signed-off-by: Tim Lehr <tim.lehr@disneyanimation.com>
1 parent 33259d8 commit 8d18f5c

14 files changed

+919
-102
lines changed

src/otio_aaf_adapter/adapters/aaf_adapter/aaf_writer.py

Lines changed: 255 additions & 33 deletions
Large diffs are not rendered by default.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright Contributors to the OpenTimelineIO project
3+
4+
import aaf2
5+
import opentimelineio as otio
6+
7+
8+
# Plugin custom hook names
9+
HOOK_PRE_READ_TRANSCRIBE = "otio_aaf_pre_read_transcribe"
10+
HOOK_POST_READ_TRANSCRIBE = "otio_aaf_post_read_transcribe"
11+
HOOK_PRE_WRITE_TRANSCRIBE = "otio_aaf_pre_write_transcribe"
12+
HOOK_POST_WRITE_TRANSCRIBE = "otio_aaf_post_write_transcribe"
13+
14+
15+
def run_pre_write_transcribe_hook(
16+
timeline: otio.schema.Timeline,
17+
write_filepath: str,
18+
aaf_handle: aaf2.file.AAFFile,
19+
embed_essence: bool,
20+
extra_kwargs: dict
21+
) -> otio.schema.Timeline:
22+
"""This hook runs on write, just before the timeline got translated to pyaaf2
23+
data."""
24+
if HOOK_PRE_WRITE_TRANSCRIBE in otio.hooks.names():
25+
extra_kwargs.update({
26+
"write_filepath": write_filepath,
27+
"aaf_handle": aaf_handle,
28+
"embed_essence": embed_essence,
29+
})
30+
return otio.hooks.run(HOOK_PRE_WRITE_TRANSCRIBE, timeline, extra_kwargs)
31+
return timeline
32+
33+
34+
def run_post_write_transcribe_hook(
35+
timeline: otio.schema.Timeline,
36+
write_filepath: str,
37+
aaf_handle: aaf2.file.AAFFile,
38+
embed_essence: bool,
39+
extra_kwargs: dict
40+
) -> otio.schema.Timeline:
41+
"""This hook runs on write, just after the timeline gets translated to pyaaf2 data.
42+
"""
43+
if HOOK_POST_WRITE_TRANSCRIBE in otio.hooks.names():
44+
extra_kwargs.update({
45+
"write_filepath": write_filepath,
46+
"aaf_handle": aaf_handle,
47+
"embed_essence": embed_essence,
48+
})
49+
return otio.hooks.run(HOOK_POST_WRITE_TRANSCRIBE, timeline, extra_kwargs)
50+
return timeline
51+
52+
53+
def run_pre_read_transcribe_hook(
54+
read_filepath: str,
55+
aaf_handle: aaf2.file.AAFFile,
56+
extra_kwargs: dict
57+
) -> None:
58+
"""This hook runs on read, just before the timeline gets translated from pyaaf2
59+
to OTIO data. It can be useful to manipulate the AAF data directly before the
60+
transcribing occurs. The hook doesn't return a timeline, since it runs before the
61+
Timeline object has been transcribed."""
62+
if HOOK_PRE_WRITE_TRANSCRIBE in otio.hooks.names():
63+
extra_kwargs.update({
64+
"read_filepath": read_filepath,
65+
"aaf_handle": aaf_handle,
66+
})
67+
otio.hooks.run(HOOK_PRE_READ_TRANSCRIBE, tl=None, extra_args=extra_kwargs)
68+
69+
70+
def run_post_read_transcribe_hook(
71+
timeline: otio.schema.Timeline,
72+
read_filepath: str,
73+
aaf_handle: aaf2.file.AAFFile,
74+
extra_kwargs: dict
75+
) -> otio.schema.Timeline:
76+
"""This hook runs on read, just after the timeline got translated to OTIO data,
77+
but before it is simplified. Possible use cases could be logic to extract and
78+
transcode media from the AAF.
79+
"""
80+
if HOOK_POST_WRITE_TRANSCRIBE in otio.hooks.names():
81+
extra_kwargs.update({
82+
"read_filepath": read_filepath,
83+
"aaf_handle": aaf_handle
84+
})
85+
return otio.hooks.run(HOOK_POST_WRITE_TRANSCRIBE,
86+
tl=timeline,
87+
extra_args=extra_kwargs)
88+
return timeline

src/otio_aaf_adapter/adapters/advanced_authoring_format.py

Lines changed: 118 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
import collections
1616
import fractions
17+
from typing import List
18+
1719
import opentimelineio as otio
1820

1921
lib_path = os.environ.get("OTIO_AAF_PYTHON_LIB")
@@ -27,6 +29,7 @@
2729
import aaf2.core # noqa: E402
2830
import aaf2.misc # noqa: E402
2931
from otio_aaf_adapter.adapters.aaf_adapter import aaf_writer # noqa: E402
32+
from otio_aaf_adapter.adapters.aaf_adapter import hooks # noqa: E402
3033

3134

3235
debug = False
@@ -268,35 +271,36 @@ def _convert_rgb_to_marker_color(rgb_dict):
268271

269272
def _find_timecode_mobs(item):
270273
mobs = [item.mob]
271-
272-
for c in item.walk():
273-
if isinstance(c, aaf2.components.SourceClip):
274-
mob = c.mob
275-
if mob:
276-
mobs.append(mob)
274+
try:
275+
for c in item.walk():
276+
if isinstance(c, aaf2.components.SourceClip):
277+
mob = c.mob
278+
if mob:
279+
mobs.append(mob)
280+
else:
281+
continue
277282
else:
283+
# This could be 'EssenceGroup', 'Pulldown' or other segment
284+
# subclasses
285+
# For example:
286+
# An EssenceGroup is a Segment that has one or more
287+
# alternate choices, each of which represent different variations
288+
# of one actual piece of content.
289+
# According to the AAF Object Specification and Edit Protocol
290+
# documents:
291+
# "Typically the different representations vary in essence format,
292+
# compression, or frame size. The application is responsible for
293+
# choosing the appropriate implementation of the essence."
294+
# It also says they should all have the same length, but
295+
# there might be nested Sequences inside which we're not attempting
296+
# to handle here (yet). We'll need a concrete example to ensure
297+
# we're doing the right thing.
298+
# TODO: Is the Timecode for an EssenceGroup correct?
299+
# TODO: Try CountChoices() and ChoiceAt(i)
300+
# For now, lets just skip it.
278301
continue
279-
else:
280-
# This could be 'EssenceGroup', 'Pulldown' or other segment
281-
# subclasses
282-
# For example:
283-
# An EssenceGroup is a Segment that has one or more
284-
# alternate choices, each of which represent different variations
285-
# of one actual piece of content.
286-
# According to the AAF Object Specification and Edit Protocol
287-
# documents:
288-
# "Typically the different representations vary in essence format,
289-
# compression, or frame size. The application is responsible for
290-
# choosing the appropriate implementation of the essence."
291-
# It also says they should all have the same length, but
292-
# there might be nested Sequences inside which we're not attempting
293-
# to handle here (yet). We'll need a concrete example to ensure
294-
# we're doing the right thing.
295-
# TODO: Is the Timecode for an EssenceGroup correct?
296-
# TODO: Try CountChoices() and ChoiceAt(i)
297-
# For now, lets just skip it.
298-
continue
299-
302+
except NotImplementedError as err:
303+
_transcribe_log(f"Couldn't walk component. Skip:\n{repr(err)}")
300304
return mobs
301305

302306

@@ -1580,25 +1584,23 @@ def _get_mobs_for_transcription(storage):
15801584

15811585

15821586
def read_from_file(
1583-
filepath,
1584-
simplify=True,
1585-
transcribe_log=False,
1586-
attach_markers=True,
1587-
bake_keyframed_properties=False
1588-
):
1587+
filepath: str,
1588+
simplify: bool = True,
1589+
transcribe_log: bool = False,
1590+
attach_markers: bool = True,
1591+
bake_keyframed_properties: bool = False,
1592+
**kwargs
1593+
) -> otio.schema.Timeline:
15891594
"""Reads AAF content from `filepath` and outputs an OTIO timeline object.
15901595
15911596
Args:
1592-
filepath (str): AAF filepath
1593-
simplify (bool, optional): simplify timeline structure by stripping empty items
1594-
transcribe_log (bool, optional): log activity as items are getting transcribed
1595-
attach_markers (bool, optional): attaches markers to their appropriate items
1597+
filepath: AAF filepath
1598+
simplify: simplify timeline structure by stripping empty items
1599+
transcribe_log: log activity as items are getting transcribed
1600+
attach_markers: attaches markers to their appropriate items
15961601
like clip, gap. etc on the track
1597-
bake_keyframed_properties (bool, optional): bakes animated property values
1598-
for each frame in a source clip
1599-
Returns:
1600-
otio.schema.Timeline
1601-
1602+
bake_keyframed_properties: bakes animated property values
1603+
for each frame in a source clip
16021604
"""
16031605
# 'activate' transcribe logging if adapter argument is provided.
16041606
# Note that a global 'switch' is used in order to avoid
@@ -1612,41 +1614,86 @@ def read_from_file(
16121614
# Note: We're skipping: aaf_file.header
16131615
# Is there something valuable in there?
16141616

1617+
# trigger adapter specific pre-transcribe read hook
1618+
hooks.run_pre_read_transcribe_hook(
1619+
read_filepath=filepath,
1620+
aaf_handle=aaf_file,
1621+
extra_kwargs=kwargs.get(
1622+
"hook_function_argument_map", {}
1623+
)
1624+
)
1625+
16151626
storage = aaf_file.content
16161627
mobs_to_transcribe = _get_mobs_for_transcription(storage)
16171628

1618-
result = _transcribe(mobs_to_transcribe, parents=list(), edit_rate=None)
1629+
timeline = _transcribe(mobs_to_transcribe, parents=list(), edit_rate=None)
1630+
1631+
# trigger adapter specific post-transcribe read hook
1632+
hooks.run_post_read_transcribe_hook(
1633+
timeline=timeline,
1634+
read_filepath=filepath,
1635+
aaf_handle=aaf_file,
1636+
extra_kwargs=kwargs.get(
1637+
"hook_function_argument_map", {}
1638+
)
1639+
)
16191640

16201641
# OTIO represents transitions a bit different than AAF, so
16211642
# we need to iterate over them and modify the items on either side.
16221643
# Note this needs to be done before attaching markers, marker
16231644
# positions are not stored with transition length offsets
1624-
_fix_transitions(result)
1645+
_fix_transitions(timeline)
16251646

16261647
# Attach marker to the appropriate clip, gap etc.
16271648
if attach_markers:
1628-
result = _attach_markers(result)
1649+
timeline = _attach_markers(timeline)
16291650

16301651
# AAF is typically more deeply nested than OTIO.
16311652
# Let's try to simplify the structure by collapsing or removing
16321653
# unnecessary stuff.
16331654
if simplify:
1634-
result = _simplify(result)
1655+
timeline = _simplify(timeline)
16351656

16361657
# Reset transcribe_log debugging
16371658
_TRANSCRIBE_DEBUG = False
1638-
return result
1659+
return timeline
16391660

16401661

1641-
def write_to_file(input_otio, filepath, **kwargs):
1662+
def write_to_file(input_otio, filepath, embed_essence=False,
1663+
create_edgecode=True, **kwargs):
1664+
"""Serialize `input_otio` to an AAF file at `filepath`.
1665+
1666+
Args:
1667+
input_otio(otio.schema.Timeline): input timeline
1668+
filepath(str): output filepath
1669+
embed_essence(Optional[bool]): if `True`, media essence will be included in AAF
1670+
create_edgecode(bool): if `True` each clip will get an EdgeCode slot
1671+
assigned that defines the Avid Frame Count Start / End.
1672+
**kwargs: extra adapter arguments
1673+
1674+
"""
16421675

16431676
with aaf2.open(filepath, "w") as f:
1677+
# trigger adapter specific pre-transcribe write hook
1678+
hook_tl = hooks.run_pre_write_transcribe_hook(
1679+
timeline=input_otio,
1680+
write_filepath=filepath,
1681+
aaf_handle=f,
1682+
embed_essence=embed_essence,
1683+
extra_kwargs=kwargs.get(
1684+
"hook_function_argument_map", {}
1685+
)
1686+
)
16441687

1645-
timeline = aaf_writer._stackify_nested_groups(input_otio)
1688+
timeline = aaf_writer._stackify_nested_groups(hook_tl)
16461689

16471690
aaf_writer.validate_metadata(timeline)
16481691

1649-
otio2aaf = aaf_writer.AAFFileTranscriber(timeline, f, **kwargs)
1692+
otio2aaf = aaf_writer.AAFFileTranscriber(input_otio=timeline,
1693+
aaf_file=f,
1694+
embed_essence=embed_essence,
1695+
create_edgecode=create_edgecode,
1696+
**kwargs)
16501697

16511698
if not isinstance(timeline, otio.schema.Timeline):
16521699
raise otio.exceptions.NotSupportedError(
@@ -1671,3 +1718,24 @@ def write_to_file(input_otio, filepath, **kwargs):
16711718
# This is required for compatibility with DaVinci Resolve.
16721719
if default_edit_rate or input_otio.global_start_time:
16731720
otio2aaf.add_timecode(input_otio, default_edit_rate)
1721+
1722+
# trigger adapter specific post-transcribe write hook
1723+
hooks.run_post_write_transcribe_hook(
1724+
timeline=timeline,
1725+
write_filepath=filepath,
1726+
aaf_handle=f,
1727+
embed_essence=embed_essence,
1728+
extra_kwargs=kwargs.get(
1729+
"hook_function_argument_map", {}
1730+
)
1731+
)
1732+
1733+
1734+
def adapter_hook_names() -> List[str]:
1735+
"""Returns names of custom hooks implemented by this adapter."""
1736+
return [
1737+
hooks.HOOK_POST_READ_TRANSCRIBE,
1738+
hooks.HOOK_POST_WRITE_TRANSCRIBE,
1739+
hooks.HOOK_PRE_READ_TRANSCRIBE,
1740+
hooks.HOOK_PRE_WRITE_TRANSCRIBE
1741+
]

src/otio_aaf_adapter/plugin_manifest.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,11 @@
88
"filepath" : "adapters/advanced_authoring_format.py",
99
"suffixes" : ["aaf"]
1010
}
11-
]
11+
],
12+
"hooks" : {
13+
"otio_aaf_pre_read_transcribe": [],
14+
"otio_aaf_post_read_transcribe": [],
15+
"otio_aaf_pre_write_transcribe": [],
16+
"otio_aaf_post_write_transcribe": []
17+
}
1218
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"OTIO_SCHEMA" : "PluginManifest.1",
3+
"hook_scripts" : [
4+
{
5+
"OTIO_SCHEMA" : "HookScript.1",
6+
"name" : "pre_aaf_write_transcribe_hook",
7+
"execution_scope" : "in process",
8+
"filepath" : "pre_aaf_write_transcribe_hook.py"
9+
},
10+
{
11+
"OTIO_SCHEMA" : "HookScript.1",
12+
"name" : "post_aaf_write_transcribe_hook",
13+
"execution_scope" : "in process",
14+
"filepath" : "post_aaf_write_transcribe_hook.py"
15+
},
16+
{
17+
"OTIO_SCHEMA" : "HookScript.1",
18+
"name" : "pre_aaf_read_transcribe_hook",
19+
"execution_scope" : "in process",
20+
"filepath" : "pre_aaf_read_transcribe_hook.py"
21+
},
22+
{
23+
"OTIO_SCHEMA" : "HookScript.1",
24+
"name" : "post_aaf_read_transcribe_hook",
25+
"execution_scope" : "in process",
26+
"filepath" : "post_aaf_read_transcribe_hook.py"
27+
}
28+
],
29+
"hooks" : {
30+
"otio_aaf_pre_write_transcribe": ["pre_aaf_write_transcribe_hook"],
31+
"otio_aaf_post_write_transcribe": ["post_aaf_write_transcribe_hook"],
32+
"otio_aaf_pre_read_transcribe": [],
33+
"otio_aaf_post_read_transcribe": []
34+
}
35+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Example hook that runs post-transcription on a read operation.
2+
This hook could be used to extract and transcode essence data from the AAF for
3+
consumption outside of Avid MC.
4+
"""
5+
from otio_aaf_adapter.adapters.aaf_adapter.aaf_writer import AAFAdapterError
6+
7+
8+
def hook_function(in_timeline, argument_map=None):
9+
if argument_map.get("test_post_hook_raise", False):
10+
raise AAFAdapterError()
11+
12+
return in_timeline

0 commit comments

Comments
 (0)