Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Writer: Added pre- / post-transcribe hooks to facilitate media essence embedding #43

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 255 additions & 33 deletions src/otio_aaf_adapter/adapters/aaf_adapter/aaf_writer.py

Large diffs are not rendered by default.

88 changes: 88 additions & 0 deletions src/otio_aaf_adapter/adapters/aaf_adapter/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright Contributors to the OpenTimelineIO project

import aaf2
import opentimelineio as otio


# Plugin custom hook names
HOOK_PRE_READ_TRANSCRIBE = "otio_aaf_pre_read_transcribe"
HOOK_POST_READ_TRANSCRIBE = "otio_aaf_post_read_transcribe"
HOOK_PRE_WRITE_TRANSCRIBE = "otio_aaf_pre_write_transcribe"
HOOK_POST_WRITE_TRANSCRIBE = "otio_aaf_post_write_transcribe"


def run_pre_write_transcribe_hook(
timeline: otio.schema.Timeline,
write_filepath: str,
aaf_handle: aaf2.file.AAFFile,
embed_essence: bool,
extra_kwargs: dict
) -> otio.schema.Timeline:
"""This hook runs on write, just before the timeline got translated to pyaaf2
data."""
if HOOK_PRE_WRITE_TRANSCRIBE in otio.hooks.names():
extra_kwargs.update({
"write_filepath": write_filepath,
"aaf_handle": aaf_handle,
"embed_essence": embed_essence,
})
return otio.hooks.run(HOOK_PRE_WRITE_TRANSCRIBE, timeline, extra_kwargs)
return timeline


def run_post_write_transcribe_hook(
timeline: otio.schema.Timeline,
write_filepath: str,
aaf_handle: aaf2.file.AAFFile,
embed_essence: bool,
extra_kwargs: dict
) -> otio.schema.Timeline:
"""This hook runs on write, just after the timeline gets translated to pyaaf2 data.
"""
if HOOK_POST_WRITE_TRANSCRIBE in otio.hooks.names():
extra_kwargs.update({
"write_filepath": write_filepath,
"aaf_handle": aaf_handle,
"embed_essence": embed_essence,
})
return otio.hooks.run(HOOK_POST_WRITE_TRANSCRIBE, timeline, extra_kwargs)
return timeline


def run_pre_read_transcribe_hook(
read_filepath: str,
aaf_handle: aaf2.file.AAFFile,
extra_kwargs: dict
) -> None:
"""This hook runs on read, just before the timeline gets translated from pyaaf2
to OTIO data. It can be useful to manipulate the AAF data directly before the
transcribing occurs. The hook doesn't return a timeline, since it runs before the
Timeline object has been transcribed."""
if HOOK_PRE_WRITE_TRANSCRIBE in otio.hooks.names():
extra_kwargs.update({
"read_filepath": read_filepath,
"aaf_handle": aaf_handle,
})
otio.hooks.run(HOOK_PRE_READ_TRANSCRIBE, tl=None, extra_args=extra_kwargs)


def run_post_read_transcribe_hook(
timeline: otio.schema.Timeline,
read_filepath: str,
aaf_handle: aaf2.file.AAFFile,
extra_kwargs: dict
) -> otio.schema.Timeline:
"""This hook runs on read, just after the timeline got translated to OTIO data,
but before it is simplified. Possible use cases could be logic to extract and
transcode media from the AAF.
"""
if HOOK_POST_WRITE_TRANSCRIBE in otio.hooks.names():
extra_kwargs.update({
"read_filepath": read_filepath,
"aaf_handle": aaf_handle
})
return otio.hooks.run(HOOK_POST_WRITE_TRANSCRIBE,
tl=timeline,
extra_args=extra_kwargs)
return timeline
168 changes: 118 additions & 50 deletions src/otio_aaf_adapter/adapters/advanced_authoring_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

import collections
import fractions
from typing import List

import opentimelineio as otio

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


debug = False
Expand Down Expand Up @@ -268,35 +271,36 @@ def _convert_rgb_to_marker_color(rgb_dict):

def _find_timecode_mobs(item):
mobs = [item.mob]

for c in item.walk():
if isinstance(c, aaf2.components.SourceClip):
mob = c.mob
if mob:
mobs.append(mob)
try:
for c in item.walk():
if isinstance(c, aaf2.components.SourceClip):
mob = c.mob
if mob:
mobs.append(mob)
else:
continue
else:
# This could be 'EssenceGroup', 'Pulldown' or other segment
# subclasses
# For example:
# An EssenceGroup is a Segment that has one or more
# alternate choices, each of which represent different variations
# of one actual piece of content.
# According to the AAF Object Specification and Edit Protocol
# documents:
# "Typically the different representations vary in essence format,
# compression, or frame size. The application is responsible for
# choosing the appropriate implementation of the essence."
# It also says they should all have the same length, but
# there might be nested Sequences inside which we're not attempting
# to handle here (yet). We'll need a concrete example to ensure
# we're doing the right thing.
# TODO: Is the Timecode for an EssenceGroup correct?
# TODO: Try CountChoices() and ChoiceAt(i)
# For now, lets just skip it.
continue
else:
# This could be 'EssenceGroup', 'Pulldown' or other segment
# subclasses
# For example:
# An EssenceGroup is a Segment that has one or more
# alternate choices, each of which represent different variations
# of one actual piece of content.
# According to the AAF Object Specification and Edit Protocol
# documents:
# "Typically the different representations vary in essence format,
# compression, or frame size. The application is responsible for
# choosing the appropriate implementation of the essence."
# It also says they should all have the same length, but
# there might be nested Sequences inside which we're not attempting
# to handle here (yet). We'll need a concrete example to ensure
# we're doing the right thing.
# TODO: Is the Timecode for an EssenceGroup correct?
# TODO: Try CountChoices() and ChoiceAt(i)
# For now, lets just skip it.
continue

except NotImplementedError as err:
_transcribe_log(f"Couldn't walk component. Skip:\n{repr(err)}")
return mobs


Expand Down Expand Up @@ -1580,25 +1584,23 @@ def _get_mobs_for_transcription(storage):


def read_from_file(
filepath,
simplify=True,
transcribe_log=False,
attach_markers=True,
bake_keyframed_properties=False
):
filepath: str,
simplify: bool = True,
transcribe_log: bool = False,
attach_markers: bool = True,
bake_keyframed_properties: bool = False,
**kwargs
) -> otio.schema.Timeline:
"""Reads AAF content from `filepath` and outputs an OTIO timeline object.

Args:
filepath (str): AAF filepath
simplify (bool, optional): simplify timeline structure by stripping empty items
transcribe_log (bool, optional): log activity as items are getting transcribed
attach_markers (bool, optional): attaches markers to their appropriate items
filepath: AAF filepath
simplify: simplify timeline structure by stripping empty items
transcribe_log: log activity as items are getting transcribed
attach_markers: attaches markers to their appropriate items
like clip, gap. etc on the track
bake_keyframed_properties (bool, optional): bakes animated property values
for each frame in a source clip
Returns:
otio.schema.Timeline

bake_keyframed_properties: bakes animated property values
for each frame in a source clip
"""
# 'activate' transcribe logging if adapter argument is provided.
# Note that a global 'switch' is used in order to avoid
Expand All @@ -1612,41 +1614,86 @@ def read_from_file(
# Note: We're skipping: aaf_file.header
# Is there something valuable in there?

# trigger adapter specific pre-transcribe read hook
hooks.run_pre_read_transcribe_hook(
read_filepath=filepath,
aaf_handle=aaf_file,
extra_kwargs=kwargs.get(
"hook_function_argument_map", {}
)
)

storage = aaf_file.content
mobs_to_transcribe = _get_mobs_for_transcription(storage)

result = _transcribe(mobs_to_transcribe, parents=list(), edit_rate=None)
timeline = _transcribe(mobs_to_transcribe, parents=list(), edit_rate=None)

# trigger adapter specific post-transcribe read hook
hooks.run_post_read_transcribe_hook(
timeline=timeline,
read_filepath=filepath,
aaf_handle=aaf_file,
extra_kwargs=kwargs.get(
"hook_function_argument_map", {}
)
)

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

# Attach marker to the appropriate clip, gap etc.
if attach_markers:
result = _attach_markers(result)
timeline = _attach_markers(timeline)

# AAF is typically more deeply nested than OTIO.
# Let's try to simplify the structure by collapsing or removing
# unnecessary stuff.
if simplify:
result = _simplify(result)
timeline = _simplify(timeline)

# Reset transcribe_log debugging
_TRANSCRIBE_DEBUG = False
return result
return timeline


def write_to_file(input_otio, filepath, **kwargs):
def write_to_file(input_otio, filepath, embed_essence=False,
create_edgecode=True, **kwargs):
"""Serialize `input_otio` to an AAF file at `filepath`.

Args:
input_otio(otio.schema.Timeline): input timeline
filepath(str): output filepath
embed_essence(Optional[bool]): if `True`, media essence will be included in AAF
create_edgecode(bool): if `True` each clip will get an EdgeCode slot
assigned that defines the Avid Frame Count Start / End.
**kwargs: extra adapter arguments

"""

with aaf2.open(filepath, "w") as f:
# trigger adapter specific pre-transcribe write hook
hook_tl = hooks.run_pre_write_transcribe_hook(
timeline=input_otio,
write_filepath=filepath,
aaf_handle=f,
embed_essence=embed_essence,
extra_kwargs=kwargs.get(
"hook_function_argument_map", {}
)
)

timeline = aaf_writer._stackify_nested_groups(input_otio)
timeline = aaf_writer._stackify_nested_groups(hook_tl)

aaf_writer.validate_metadata(timeline)

otio2aaf = aaf_writer.AAFFileTranscriber(timeline, f, **kwargs)
otio2aaf = aaf_writer.AAFFileTranscriber(input_otio=timeline,
aaf_file=f,
embed_essence=embed_essence,
create_edgecode=create_edgecode,
**kwargs)

if not isinstance(timeline, otio.schema.Timeline):
raise otio.exceptions.NotSupportedError(
Expand All @@ -1671,3 +1718,24 @@ def write_to_file(input_otio, filepath, **kwargs):
# 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)

# trigger adapter specific post-transcribe write hook
hooks.run_post_write_transcribe_hook(
timeline=timeline,
write_filepath=filepath,
aaf_handle=f,
embed_essence=embed_essence,
extra_kwargs=kwargs.get(
"hook_function_argument_map", {}
)
)


def adapter_hook_names() -> List[str]:
"""Returns names of custom hooks implemented by this adapter."""
return [
hooks.HOOK_POST_READ_TRANSCRIBE,
hooks.HOOK_POST_WRITE_TRANSCRIBE,
hooks.HOOK_PRE_READ_TRANSCRIBE,
hooks.HOOK_PRE_WRITE_TRANSCRIBE
]
8 changes: 7 additions & 1 deletion src/otio_aaf_adapter/plugin_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,11 @@
"filepath" : "adapters/advanced_authoring_format.py",
"suffixes" : ["aaf"]
}
]
],
"hooks" : {
"otio_aaf_pre_read_transcribe": [],
"otio_aaf_post_read_transcribe": [],
"otio_aaf_pre_write_transcribe": [],
"otio_aaf_post_write_transcribe": []
}
}
35 changes: 35 additions & 0 deletions tests/hooks_plugin_example/plugin_manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"OTIO_SCHEMA" : "PluginManifest.1",
"hook_scripts" : [
{
"OTIO_SCHEMA" : "HookScript.1",
"name" : "pre_aaf_write_transcribe_hook",
"execution_scope" : "in process",
"filepath" : "pre_aaf_write_transcribe_hook.py"
},
{
"OTIO_SCHEMA" : "HookScript.1",
"name" : "post_aaf_write_transcribe_hook",
"execution_scope" : "in process",
"filepath" : "post_aaf_write_transcribe_hook.py"
},
{
"OTIO_SCHEMA" : "HookScript.1",
"name" : "pre_aaf_read_transcribe_hook",
"execution_scope" : "in process",
"filepath" : "pre_aaf_read_transcribe_hook.py"
},
{
"OTIO_SCHEMA" : "HookScript.1",
"name" : "post_aaf_read_transcribe_hook",
"execution_scope" : "in process",
"filepath" : "post_aaf_read_transcribe_hook.py"
}
],
"hooks" : {
"otio_aaf_pre_write_transcribe": ["pre_aaf_write_transcribe_hook"],
"otio_aaf_post_write_transcribe": ["post_aaf_write_transcribe_hook"],
"otio_aaf_pre_read_transcribe": [],
"otio_aaf_post_read_transcribe": []
}
}
12 changes: 12 additions & 0 deletions tests/hooks_plugin_example/post_aaf_read_transcribe_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Example hook that runs post-transcription on a read operation.
This hook could be used to extract and transcode essence data from the AAF for
consumption outside of Avid MC.
"""
from otio_aaf_adapter.adapters.aaf_adapter.aaf_writer import AAFAdapterError


def hook_function(in_timeline, argument_map=None):
if argument_map.get("test_post_hook_raise", False):
raise AAFAdapterError()

return in_timeline
Loading
Loading