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

Added support for custom adapter hooks #1801

Merged
merged 4 commits into from
Mar 27, 2025
Merged
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
89 changes: 89 additions & 0 deletions docs/tutorials/write-a-hookscript.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,92 @@ def hook_function(in_timeline, argument_map=None):
```

Please note that if a "post adapter write hook" changes `in_timeline` in any way, the api will not automatically update the already serialized file. The changes will only exist in the in-memory object, because the hook runs _after_ the file is serialized to disk.

## Implementing Adapter-specific hooks

While OTIO ships with a set of pre-defined hooks (e.g. `pre_adapter_write`), you can also define your own hooks in your adapter.
These can be useful to give the user more fine-grained control over the execution of your adapter and make it work for their specific workflow.
A good example is media embedding within Avids AAF files: Depending on the workflow, media references might have to be transcoded to be compatible with the AAF format.
To achieve this, the AAF adapter could define a hook which users can leverage to transcode the files before embedding is attempted.

To define a custom hook in your adapter, you need to implement the `adapter_hook_names` function in your adapter module.
You can define as many hooks as you like, but try to use the native hooks where possible to keep the API consistent.

```python
# my_aaf_adapter.py

def read_from_file(self, filepath, **kwargs):
...

def write_to_file(self, timeline, filepath, **kwargs):
...

def adapter_hook_names() -> List[str]:
"""Returns names of custom hooks implemented by this adapter."""
return [
"my_custom_adapter_hook"
]
```

The new hooks also need to be added to the adapter plugin manifest.

```json
{
"OTIO_SCHEMA" : "PluginManifest.1",
"adapters" : [
{
"OTIO_SCHEMA" : "Adapter.1",
"name" : "My AAF Adapter",
"execution_scope" : "in process",
"filepath" : "adapters/my_aaf_adapter.py",
"suffixes" : ["aaf"]
}
],
"hook_scripts" : [
{
"OTIO_SCHEMA" : "HookScript.1",
"name" : "script_attached_to_custom_adapter_hook",
"filepath" : "my_custom_adapter_hook_script.py"
}
],
"hooks" : {
"pre_adapter_write" : [],
"post_adapter_read" : [],
"post_adapter_write" : [],
"post_media_linker" : [],
"my_custom_adapter_hook" : ["script_attached_to_custom_adapter_hook"]
}
}
```

A custom hook script might look like this:

```python
# my_custom_adapter_hook_script.py

def hook_function(timeline, custom_argument, argument_map=None):
# Do something with the timeline
print(
f"Running custom adapter hook with custom argument value '{custom_argument}'"
f"and argument map: {argument_map}"
)
return timeline
```

Attached hook scripts can then be run anywhere using the `otio.hooks.run` function:

```python
# my_aaf_adapter.py

def write_to_file(self, timeline, filepath, **kwargs):
# Do something
...
# Run custom hook script with it's custom arguments and pass hook_argument_map along
otio.hooks.run(
"my_custom_adapter_hook", timeline,
custom_argument="some_value",
argument_map=kwargs.get("hook_argument_map", {})
)
...
# Do something more
```
72 changes: 54 additions & 18 deletions src/py-opentimelineio/opentimelineio/adapters/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import inspect
import collections
import copy
from typing import List

from .. import (
core,
Expand Down Expand Up @@ -99,6 +100,21 @@ def read_from_file(
media_linker_argument_map or {}
)

hook_function_argument_map = copy.deepcopy(
hook_function_argument_map or {}
)
hook_function_argument_map['adapter_arguments'] = copy.deepcopy(
adapter_argument_map
)
hook_function_argument_map['media_linker_argument_map'] = (
media_linker_argument_map
)

if self.has_feature("hooks"):
adapter_argument_map[
"hook_function_argument_map"
] = hook_function_argument_map

result = None

if (
Expand All @@ -119,15 +135,6 @@ def read_from_file(
**adapter_argument_map
)

hook_function_argument_map = copy.deepcopy(
hook_function_argument_map or {}
)
hook_function_argument_map['adapter_arguments'] = copy.deepcopy(
adapter_argument_map
)
hook_function_argument_map['media_linker_argument_map'] = (
media_linker_argument_map
)
result = hooks.run(
"post_adapter_read",
result,
Expand Down Expand Up @@ -174,6 +181,11 @@ def write_to_file(
# Store file path for use in hooks
hook_function_argument_map['_filepath'] = filepath

if self.has_feature("hooks"):
adapter_argument_map[
"hook_function_argument_map"
] = hook_function_argument_map

input_otio = hooks.run("pre_adapter_write", input_otio,
extra_args=hook_function_argument_map)
if (
Expand Down Expand Up @@ -210,13 +222,6 @@ def read_from_string(
**adapter_argument_map
):
"""Call the read_from_string function on this adapter."""

result = self._execute_function(
"read_from_string",
input_str=input_str,
**adapter_argument_map
)

hook_function_argument_map = copy.deepcopy(
hook_function_argument_map or {}
)
Expand All @@ -227,6 +232,17 @@ def read_from_string(
media_linker_argument_map
)

if self.has_feature("hooks"):
adapter_argument_map[
"hook_function_argument_map"
] = hook_function_argument_map

result = self._execute_function(
"read_from_string",
input_str=input_str,
**adapter_argument_map
)

result = hooks.run(
"post_adapter_read",
result,
Expand Down Expand Up @@ -277,6 +293,16 @@ def write_to_string(
**adapter_argument_map
)

def adapter_hook_names(self) -> List[str]:
"""Returns a list of hooks claimed by the adapter.

In addition to the hook being declared in the manifest, it should also be
returned here, so it can be attributed to the adapter.
"""
if not self.has_feature("hooks"):
return []
return self._execute_function("adapter_hook_names")

def __str__(self):
return (
"Adapter("
Expand Down Expand Up @@ -312,8 +338,9 @@ def plugin_info_map(self):
result["supported features"] = features

for feature in sorted(_FEATURE_MAP.keys()):
if feature in ["read", "write"]:
if feature in ["read", "write", "hooks"]:
continue

if self.has_feature(feature):
features[feature] = collections.OrderedDict()

Expand All @@ -330,6 +357,14 @@ def plugin_info_map(self):
features[feature]["args"] = args.args
features[feature]["doc"] = docs

# check if there are any adapter specific-hooks and get their names
if self.has_feature("hooks"):
adapter_hooks_names_fn = getattr(
self.module(), _FEATURE_MAP["hooks"][0], None
)
if adapter_hooks_names_fn:
features["hooks"] = adapter_hooks_names_fn()

return result


Expand Down Expand Up @@ -372,5 +407,6 @@ def _with_linked_media_references(
'read': ['read_from_file', 'read_from_string'],
'write_to_file': ['write_to_file'],
'write_to_string': ['write_to_string'],
'write': ['write_to_file', 'write_to_string']
'write': ['write_to_file', 'write_to_string'],
'hooks': ['adapter_hook_names']
}
9 changes: 9 additions & 0 deletions src/py-opentimelineio/opentimelineio/console/otiopluginfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,16 @@ def _supported_features_formatted(feature_map, _):
if feature_map:
print(" explicit supported features:")
for thing, args in feature_map.items():
# skip hooks, they are treated separately, see below
if thing == "hooks":
continue
print(" {} args: {}".format(thing, args['args']))

# check if there are any adapter specific-hooks implemented
adapter_hook_names = feature_map.get("hooks", [])
if adapter_hook_names:
print(" adapter hooks: {}".format(adapter_hook_names))

extra_features = []
for kind in ["read", "write"]:
if (
Expand Down
6 changes: 5 additions & 1 deletion tests/baselines/adapter_plugin_manifest.plugin_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@
},
{
"FROM_TEST_FILE" : "post_write_hookscript_example.json"
},
{
"FROM_TEST_FILE" : "custom_adapter_hookscript_example.json"
}
],
"hooks" : {
"pre_adapter_write" : ["example hook", "example hook"],
"post_adapter_read" : [],
"post_adapter_write" : ["post write example hook"],
"post_media_linker" : ["example hook"]
"post_media_linker" : ["example hook"],
"custom_adapter_hook": ["custom adapter hook"]
},
"version_manifests" : {
"TEST_FAMILY_NAME": {
Expand Down
5 changes: 5 additions & 0 deletions tests/baselines/custom_adapter_hookscript_example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"OTIO_SCHEMA" : "HookScript.1",
"name" : "custom adapter hook",
"filepath" : "custom_adapter_hookscript_example.py"
}
13 changes: 13 additions & 0 deletions tests/baselines/custom_adapter_hookscript_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright Contributors to the OpenTimelineIO project

"""This file is here to support the test_adapter_plugin unittest, specifically adapters
that implement their own hooks.
If you want to learn how to write your own adapter plugin, please read:
https://opentimelineio.readthedocs.io/en/latest/tutorials/write-an-adapter.html
"""


def hook_function(in_timeline, argument_map=None):
in_timeline.metadata["custom_hook"] = dict(argument_map)
return in_timeline
26 changes: 22 additions & 4 deletions tests/baselines/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,41 @@
https://opentimelineio.readthedocs.io/en/latest/tutorials/write-an-adapter.html
"""

import opentimelineio as otio

# `hook_function_argument_map` is only a required argument for adapters that implement
# custom hooks.
def read_from_file(filepath, suffix="", hook_function_argument_map=None):
import opentimelineio as otio

def read_from_file(filepath, suffix=""):
fake_tl = otio.schema.Timeline(name=filepath + str(suffix))
fake_tl.tracks.append(otio.schema.Track())
fake_tl.tracks[0].append(otio.schema.Clip(name=filepath + "_clip"))

if (hook_function_argument_map and
hook_function_argument_map.get("run_custom_hook", False)):
return otio.hooks.run(hook="custom_adapter_hook", tl=fake_tl,
extra_args=hook_function_argument_map)

return fake_tl


def read_from_string(input_str, suffix=""):
return read_from_file(input_str, suffix)
# `hook_function_argument_map` is only a required argument for adapters that implement
# custom hooks.
def read_from_string(input_str, suffix="", hook_function_argument_map=None):
tl = read_from_file(input_str, suffix, hook_function_argument_map)
return tl


# this is only required for adapters that implement custom hooks
def adapter_hook_names():
return ["custom_adapter_hook"]


# in practice, these will be in separate plugins, but for simplicity in the
# unit tests, we put this in the same file as the example adapter.
def link_media_reference(in_clip, media_linker_argument_map):
import opentimelineio as otio

d = {'from_test_linker': True}
d.update(media_linker_argument_map)
return otio.schema.MissingReference(
Expand Down
1 change: 1 addition & 0 deletions tests/test_adapter_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def test_has_feature(self):
self.assertTrue(self.adp.has_feature("read"))
self.assertTrue(self.adp.has_feature("read_from_file"))
self.assertFalse(self.adp.has_feature("write"))
self.assertTrue(self.adp.has_feature("hooks"))

def test_pass_arguments_to_adapter(self):
self.assertEqual(self.adp.read_from_file("foo", suffix=3).name, "foo3")
Expand Down
Loading
Loading