Skip to content

Commit 47d7779

Browse files
authored
Added support for custom adapter hooks (#1801)
This adds support for attributing custom hooks to adapters and executing them with `hook_function_argument_map` being passed along through the adapter IO functions. * Added adapter hooks support for otiopluginfo * Docs: Added documentation for adapter hook system --------- Signed-off-by: Tim Lehr <[email protected]>
1 parent f62b332 commit 47d7779

File tree

9 files changed

+234
-29
lines changed

9 files changed

+234
-29
lines changed

docs/tutorials/write-a-hookscript.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,92 @@ def hook_function(in_timeline, argument_map=None):
126126
```
127127

128128
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.
129+
130+
## Implementing Adapter-specific hooks
131+
132+
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.
133+
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.
134+
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.
135+
To achieve this, the AAF adapter could define a hook which users can leverage to transcode the files before embedding is attempted.
136+
137+
To define a custom hook in your adapter, you need to implement the `adapter_hook_names` function in your adapter module.
138+
You can define as many hooks as you like, but try to use the native hooks where possible to keep the API consistent.
139+
140+
```python
141+
# my_aaf_adapter.py
142+
143+
def read_from_file(self, filepath, **kwargs):
144+
...
145+
146+
def write_to_file(self, timeline, filepath, **kwargs):
147+
...
148+
149+
def adapter_hook_names() -> List[str]:
150+
"""Returns names of custom hooks implemented by this adapter."""
151+
return [
152+
"my_custom_adapter_hook"
153+
]
154+
```
155+
156+
The new hooks also need to be added to the adapter plugin manifest.
157+
158+
```json
159+
{
160+
"OTIO_SCHEMA" : "PluginManifest.1",
161+
"adapters" : [
162+
{
163+
"OTIO_SCHEMA" : "Adapter.1",
164+
"name" : "My AAF Adapter",
165+
"execution_scope" : "in process",
166+
"filepath" : "adapters/my_aaf_adapter.py",
167+
"suffixes" : ["aaf"]
168+
}
169+
],
170+
"hook_scripts" : [
171+
{
172+
"OTIO_SCHEMA" : "HookScript.1",
173+
"name" : "script_attached_to_custom_adapter_hook",
174+
"filepath" : "my_custom_adapter_hook_script.py"
175+
}
176+
],
177+
"hooks" : {
178+
"pre_adapter_write" : [],
179+
"post_adapter_read" : [],
180+
"post_adapter_write" : [],
181+
"post_media_linker" : [],
182+
"my_custom_adapter_hook" : ["script_attached_to_custom_adapter_hook"]
183+
}
184+
}
185+
```
186+
187+
A custom hook script might look like this:
188+
189+
```python
190+
# my_custom_adapter_hook_script.py
191+
192+
def hook_function(timeline, custom_argument, argument_map=None):
193+
# Do something with the timeline
194+
print(
195+
f"Running custom adapter hook with custom argument value '{custom_argument}'"
196+
f"and argument map: {argument_map}"
197+
)
198+
return timeline
199+
```
200+
201+
Attached hook scripts can then be run anywhere using the `otio.hooks.run` function:
202+
203+
```python
204+
# my_aaf_adapter.py
205+
206+
def write_to_file(self, timeline, filepath, **kwargs):
207+
# Do something
208+
...
209+
# Run custom hook script with it's custom arguments and pass hook_argument_map along
210+
otio.hooks.run(
211+
"my_custom_adapter_hook", timeline,
212+
custom_argument="some_value",
213+
argument_map=kwargs.get("hook_argument_map", {})
214+
)
215+
...
216+
# Do something more
217+
```

src/py-opentimelineio/opentimelineio/adapters/adapter.py

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import inspect
1111
import collections
1212
import copy
13+
from typing import List
1314

1415
from .. import (
1516
core,
@@ -99,6 +100,21 @@ def read_from_file(
99100
media_linker_argument_map or {}
100101
)
101102

103+
hook_function_argument_map = copy.deepcopy(
104+
hook_function_argument_map or {}
105+
)
106+
hook_function_argument_map['adapter_arguments'] = copy.deepcopy(
107+
adapter_argument_map
108+
)
109+
hook_function_argument_map['media_linker_argument_map'] = (
110+
media_linker_argument_map
111+
)
112+
113+
if self.has_feature("hooks"):
114+
adapter_argument_map[
115+
"hook_function_argument_map"
116+
] = hook_function_argument_map
117+
102118
result = None
103119

104120
if (
@@ -119,15 +135,6 @@ def read_from_file(
119135
**adapter_argument_map
120136
)
121137

122-
hook_function_argument_map = copy.deepcopy(
123-
hook_function_argument_map or {}
124-
)
125-
hook_function_argument_map['adapter_arguments'] = copy.deepcopy(
126-
adapter_argument_map
127-
)
128-
hook_function_argument_map['media_linker_argument_map'] = (
129-
media_linker_argument_map
130-
)
131138
result = hooks.run(
132139
"post_adapter_read",
133140
result,
@@ -174,6 +181,11 @@ def write_to_file(
174181
# Store file path for use in hooks
175182
hook_function_argument_map['_filepath'] = filepath
176183

184+
if self.has_feature("hooks"):
185+
adapter_argument_map[
186+
"hook_function_argument_map"
187+
] = hook_function_argument_map
188+
177189
input_otio = hooks.run("pre_adapter_write", input_otio,
178190
extra_args=hook_function_argument_map)
179191
if (
@@ -210,13 +222,6 @@ def read_from_string(
210222
**adapter_argument_map
211223
):
212224
"""Call the read_from_string function on this adapter."""
213-
214-
result = self._execute_function(
215-
"read_from_string",
216-
input_str=input_str,
217-
**adapter_argument_map
218-
)
219-
220225
hook_function_argument_map = copy.deepcopy(
221226
hook_function_argument_map or {}
222227
)
@@ -227,6 +232,17 @@ def read_from_string(
227232
media_linker_argument_map
228233
)
229234

235+
if self.has_feature("hooks"):
236+
adapter_argument_map[
237+
"hook_function_argument_map"
238+
] = hook_function_argument_map
239+
240+
result = self._execute_function(
241+
"read_from_string",
242+
input_str=input_str,
243+
**adapter_argument_map
244+
)
245+
230246
result = hooks.run(
231247
"post_adapter_read",
232248
result,
@@ -277,6 +293,16 @@ def write_to_string(
277293
**adapter_argument_map
278294
)
279295

296+
def adapter_hook_names(self) -> List[str]:
297+
"""Returns a list of hooks claimed by the adapter.
298+
299+
In addition to the hook being declared in the manifest, it should also be
300+
returned here, so it can be attributed to the adapter.
301+
"""
302+
if not self.has_feature("hooks"):
303+
return []
304+
return self._execute_function("adapter_hook_names")
305+
280306
def __str__(self):
281307
return (
282308
"Adapter("
@@ -312,8 +338,9 @@ def plugin_info_map(self):
312338
result["supported features"] = features
313339

314340
for feature in sorted(_FEATURE_MAP.keys()):
315-
if feature in ["read", "write"]:
341+
if feature in ["read", "write", "hooks"]:
316342
continue
343+
317344
if self.has_feature(feature):
318345
features[feature] = collections.OrderedDict()
319346

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

360+
# check if there are any adapter specific-hooks and get their names
361+
if self.has_feature("hooks"):
362+
adapter_hooks_names_fn = getattr(
363+
self.module(), _FEATURE_MAP["hooks"][0], None
364+
)
365+
if adapter_hooks_names_fn:
366+
features["hooks"] = adapter_hooks_names_fn()
367+
333368
return result
334369

335370

@@ -372,5 +407,6 @@ def _with_linked_media_references(
372407
'read': ['read_from_file', 'read_from_string'],
373408
'write_to_file': ['write_to_file'],
374409
'write_to_string': ['write_to_string'],
375-
'write': ['write_to_file', 'write_to_string']
410+
'write': ['write_to_file', 'write_to_string'],
411+
'hooks': ['adapter_hook_names']
376412
}

src/py-opentimelineio/opentimelineio/console/otiopluginfo.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,16 @@ def _supported_features_formatted(feature_map, _):
7575
if feature_map:
7676
print(" explicit supported features:")
7777
for thing, args in feature_map.items():
78+
# skip hooks, they are treated separately, see below
79+
if thing == "hooks":
80+
continue
7881
print(" {} args: {}".format(thing, args['args']))
82+
83+
# check if there are any adapter specific-hooks implemented
84+
adapter_hook_names = feature_map.get("hooks", [])
85+
if adapter_hook_names:
86+
print(" adapter hooks: {}".format(adapter_hook_names))
87+
7988
extra_features = []
8089
for kind in ["read", "write"]:
8190
if (

tests/baselines/adapter_plugin_manifest.plugin_manifest.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,17 @@
1616
},
1717
{
1818
"FROM_TEST_FILE" : "post_write_hookscript_example.json"
19+
},
20+
{
21+
"FROM_TEST_FILE" : "custom_adapter_hookscript_example.json"
1922
}
2023
],
2124
"hooks" : {
2225
"pre_adapter_write" : ["example hook", "example hook"],
2326
"post_adapter_read" : [],
2427
"post_adapter_write" : ["post write example hook"],
25-
"post_media_linker" : ["example hook"]
28+
"post_media_linker" : ["example hook"],
29+
"custom_adapter_hook": ["custom adapter hook"]
2630
},
2731
"version_manifests" : {
2832
"TEST_FAMILY_NAME": {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"OTIO_SCHEMA" : "HookScript.1",
3+
"name" : "custom adapter hook",
4+
"filepath" : "custom_adapter_hookscript_example.py"
5+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright Contributors to the OpenTimelineIO project
3+
4+
"""This file is here to support the test_adapter_plugin unittest, specifically adapters
5+
that implement their own hooks.
6+
If you want to learn how to write your own adapter plugin, please read:
7+
https://opentimelineio.readthedocs.io/en/latest/tutorials/write-an-adapter.html
8+
"""
9+
10+
11+
def hook_function(in_timeline, argument_map=None):
12+
in_timeline.metadata["custom_hook"] = dict(argument_map)
13+
return in_timeline

tests/baselines/example.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,41 @@
66
https://opentimelineio.readthedocs.io/en/latest/tutorials/write-an-adapter.html
77
"""
88

9-
import opentimelineio as otio
109

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

12-
def read_from_file(filepath, suffix=""):
1315
fake_tl = otio.schema.Timeline(name=filepath + str(suffix))
1416
fake_tl.tracks.append(otio.schema.Track())
1517
fake_tl.tracks[0].append(otio.schema.Clip(name=filepath + "_clip"))
18+
19+
if (hook_function_argument_map and
20+
hook_function_argument_map.get("run_custom_hook", False)):
21+
return otio.hooks.run(hook="custom_adapter_hook", tl=fake_tl,
22+
extra_args=hook_function_argument_map)
23+
1624
return fake_tl
1725

1826

19-
def read_from_string(input_str, suffix=""):
20-
return read_from_file(input_str, suffix)
27+
# `hook_function_argument_map` is only a required argument for adapters that implement
28+
# custom hooks.
29+
def read_from_string(input_str, suffix="", hook_function_argument_map=None):
30+
tl = read_from_file(input_str, suffix, hook_function_argument_map)
31+
return tl
32+
33+
34+
# this is only required for adapters that implement custom hooks
35+
def adapter_hook_names():
36+
return ["custom_adapter_hook"]
2137

2238

2339
# in practice, these will be in separate plugins, but for simplicity in the
2440
# unit tests, we put this in the same file as the example adapter.
2541
def link_media_reference(in_clip, media_linker_argument_map):
42+
import opentimelineio as otio
43+
2644
d = {'from_test_linker': True}
2745
d.update(media_linker_argument_map)
2846
return otio.schema.MissingReference(

tests/test_adapter_plugin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ def test_has_feature(self):
8989
self.assertTrue(self.adp.has_feature("read"))
9090
self.assertTrue(self.adp.has_feature("read_from_file"))
9191
self.assertFalse(self.adp.has_feature("write"))
92+
self.assertTrue(self.adp.has_feature("hooks"))
9293

9394
def test_pass_arguments_to_adapter(self):
9495
self.assertEqual(self.adp.read_from_file("foo", suffix=3).name, "foo3")

0 commit comments

Comments
 (0)