Media linkers and hooks
Pointing every clip a read produces at the right media, and running code of your own at fixed points around a read or a write.
A file that comes from somewhere else rarely points at media where it lives
here. An EDL names a reel, an AAF names a MobID, an .otio from another
facility names paths on its disks. Upstream OpenTimelineIO answers that with
two plugin points, and every language here has both.
A media linker is handed each clip a read produced, and answers with the media reference that clip should use instead, or with nothing to leave it alone. A read names the linker it wants.
A hook script is handed the whole result at a named point, and answers with what the read or write goes on with: the same object changed, or another one. Scripts are attached to a hook, in order, and each is handed what the one before it returned.
use otio_adapter::plugins::{self, HookScript, LinkerChoice, MediaLinker, PluginArguments};
use otio_core::schema::{ExternalReference, MediaReferenceData};
use otio_core::{Any, AnyDictionary, Node};
// A cut of two clips, as an .otio file would hold it.
const CUT: &str = r#"{
"OTIO_SCHEMA": "Track.1",
"name": "V1",
"kind": "Video",
"children": [
{"OTIO_SCHEMA": "Clip.2", "name": "A"},
{"OTIO_SCHEMA": "Clip.2", "name": "B"}
]
}"#;
/// One string out of an argument map.
fn text(arguments: &AnyDictionary, key: &str) -> Result<String, String> {
match arguments.get(key) {
Some(Any::String(value)) => Ok(value.clone()),
_ => Err(format!("no {key} in the arguments")),
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// A media linker is handed each clip as it is read, with the arguments
// the read was given, and answers with media it put in the document for
// the clip to use. `None` leaves the clip as it was.
plugins::registry().register_media_linker(
"proxies",
MediaLinker::new(|document, clip, arguments| {
let name = document
.try_get(clip)
.map_err(|error| error.to_string())?
.base()
.map(|base| base.name.clone())
.unwrap_or_default();
let root = text(arguments, "root")?;
Ok(Some(document.insert(Node::ExternalReference(
ExternalReference {
media: MediaReferenceData::default(),
target_url: format!("{root}/{name}.mov"),
},
))))
}),
);
// A hook script is handed the whole result, and answers with what the
// read goes on with: here the same object, stamped.
plugins::registry().register_hook_script(
"stamp",
HookScript::new(|document, target, arguments| {
let who = text(arguments, "who")?;
if let Some(base) = document
.try_get_mut(target)
.map_err(|error| error.to_string())?
.base_mut()
{
base.metadata.insert("read_by".into(), Any::String(who));
}
Ok(target)
}),
);
plugins::registry().attach_hook_script(plugins::POST_ADAPTER_READ, "stamp");
// In Rust the hooks run where you say: an adapter only parses, and
// `after_read` is what upstream runs once it has, in upstream's order.
let mut document = otio_core::from_str(CUT)?;
let mut arguments = PluginArguments {
media_linker: LinkerChoice::Named("proxies".into()),
..PluginArguments::default()
};
arguments
.media_linker_arguments
.insert("root".into(), Any::String("/proxies".into()));
arguments
.hook_arguments
.insert("who".into(), Any::String("the conform".into()));
plugins::after_read(&mut document, &arguments, AnyDictionary::new())?;
let track = document.root().ok_or("the cut has no root")?;
if let Some(Any::String(who)) = document
.try_get(track)?
.base()
.and_then(|base| base.metadata.get("read_by"))
{
println!("read by {who}");
}
for clip in document.find_clips(track)? {
let Node::Clip(found) = document.try_get(clip)? else {
continue;
};
let media = found.media_references[&found.active_media_reference_key];
if let Node::ExternalReference(external) = document.try_get(media)? {
println!("{} -> {}", found.item.base.name, external.target_url);
}
}
plugins::registry().remove_hook_script("stamp");
plugins::registry().remove_media_linker("proxies");
Ok(())
}import json
import os
import tempfile
# Python finds linkers and hook scripts the way upstream does: through a
# plugin manifest naming the modules that hold them. This one is written out
# here so the sample stands alone; a studio would keep it beside its tools and
# point OTIO_PLUGIN_MANIFEST_PATH at it once.
plugins = tempfile.mkdtemp()
with open(os.path.join(plugins, "proxies.py"), "w") as module:
module.write('''
import opentimelineio as otio
def link_media_reference(in_clip, media_linker_argument_map):
root = media_linker_argument_map["root"]
return otio.schema.ExternalReference(
target_url="{}/{}.mov".format(root, in_clip.name)
)
''')
with open(os.path.join(plugins, "stamp.py"), "w") as module:
module.write('''
def hook_function(in_timeline, argument_map=None):
in_timeline.metadata["read_by"] = argument_map["who"]
return in_timeline
''')
with open(os.path.join(plugins, "plugin_manifest.json"), "w") as manifest:
json.dump({
"OTIO_SCHEMA": "PluginManifest.1",
"media_linkers": [
{"OTIO_SCHEMA": "MediaLinker.1", "name": "proxies", "filepath": "proxies.py"},
],
"hook_scripts": [
{"OTIO_SCHEMA": "HookScript.1", "name": "stamp", "filepath": "stamp.py"},
],
"hooks": {"post_adapter_read": ["stamp"]},
}, manifest)
os.environ["OTIO_PLUGIN_MANIFEST_PATH"] = os.path.join(plugins, "plugin_manifest.json")
import opentimelineio as otio # noqa: E402
# A cut of two clips, as an .otio file would hold it.
cut = json.dumps({
"OTIO_SCHEMA": "Track.1",
"name": "V1",
"kind": "Video",
"children": [
{"OTIO_SCHEMA": "Clip.2", "name": "A"},
{"OTIO_SCHEMA": "Clip.2", "name": "B"},
],
})
# The read names the linker, and carries both sets of arguments.
track = otio.adapters.read_from_string(
cut,
"otio_json",
media_linker_name="proxies",
media_linker_argument_map={"root": "/proxies"},
hook_function_argument_map={"who": "the conform"},
)
print("read by", track.metadata["read_by"])
for clip in track.find_clips():
print(clip.name, "->", clip.media_reference.target_url)import {
init,
attachHookScript,
detachHookScript,
ExternalReference,
readFromString,
registerHookScript,
registerMediaLinker,
unregisterHookScript,
unregisterMediaLinker,
} from "@alchemist-edit/otio";
await init();
// A cut of two clips, as an .otio file would hold it.
const cut = `{
"OTIO_SCHEMA": "Track.1",
"name": "V1",
"kind": "Video",
"children": [
{"OTIO_SCHEMA": "Clip.2", "name": "A"},
{"OTIO_SCHEMA": "Clip.2", "name": "B"}
]
}`;
// A media linker is handed each clip as it is read, with the arguments the
// read was given, and answers with the media the clip should use. Answering
// nothing leaves the clip as it was; throwing stops the read.
registerMediaLinker("proxies", (clip, args) =>
new ExternalReference({ name: clip.name, targetUrl: `${args.root}/${clip.name}.mov` }),
);
// A hook script is handed the whole result, and answers with what the read
// goes on with: here the same object, stamped.
registerHookScript("stamp", (target, args) => {
target.metadata.set("read_by", String(args.who));
return target;
});
attachHookScript("post_adapter_read", "stamp");
try {
// The read names the linker, and carries both sets of arguments as JSON.
const track = readFromString("otioJson", cut, {
mediaLinker: "proxies",
mediaLinkerArguments: JSON.stringify({ root: "/proxies" }),
hookArguments: JSON.stringify({ who: "the conform" }),
});
console.log("read by", track.metadata.get("read_by"));
for (const clip of track.findClips()) {
const media = clip.mediaReference();
if (media instanceof ExternalReference) {
console.log(clip.name, "->", media.targetUrl);
}
}
} finally {
detachHookScript("post_adapter_read", "stamp");
unregisterHookScript("stamp");
unregisterMediaLinker("proxies");
}package main
import (
"fmt"
"log"
otio "github.com/alchemist-editor/otio-rust/sdk/go"
)
// A cut of two clips, as an .otio file would hold it.
const cut = `{
"OTIO_SCHEMA": "Track.1",
"name": "V1",
"kind": "Video",
"children": [
{"OTIO_SCHEMA": "Clip.2", "name": "A"},
{"OTIO_SCHEMA": "Clip.2", "name": "B"}
]
}`
func main() {
// A media linker is handed each clip as it is read, with the arguments
// the read was given, and answers with the media the clip should use.
// An empty Node leaves the clip as it was.
err := otio.RegisterMediaLinker("proxies", func(clip otio.Clip, arguments otio.Metadata) (otio.Node, error) {
name, err := clip.Name()
if err != nil {
return otio.Node{}, err
}
root, err := arguments.GetString("root")
if err != nil {
return otio.Node{}, err
}
proxy, err := otio.NewExternalReference(name, root+"/"+name+".mov")
return proxy.Node, err
})
if err != nil {
log.Fatal(err)
}
defer otio.UnregisterMediaLinker("proxies")
// A hook script is handed the whole result, and answers with what the
// read goes on with: here the same object, stamped.
err = otio.RegisterHookScript("stamp", func(target otio.Node, arguments otio.Metadata) (otio.Node, error) {
who, err := arguments.GetString("who")
if err != nil {
return otio.Node{}, err
}
return target, target.Metadata().SetString("read_by", who)
})
if err != nil {
log.Fatal(err)
}
defer otio.UnregisterHookScript("stamp")
if err := otio.AttachHookScript("post_adapter_read", "stamp"); err != nil {
log.Fatal(err)
}
defer otio.DetachHookScript("post_adapter_read", "stamp")
// The read names the linker, and carries both sets of arguments as JSON.
track, err := otio.ReadFromBytes(otio.FormatOTIOJSON, []byte(cut), &otio.ReadOptions{
MediaLinker: "proxies",
MediaLinkerArguments: `{"root": "/proxies"}`,
HookArguments: `{"who": "the conform"}`,
})
if err != nil {
log.Fatal(err)
}
defer track.Close()
who, _ := track.Metadata().GetString("read_by")
fmt.Println("read by", who)
clips, err := track.FindClips()
if err != nil {
log.Fatal(err)
}
for _, node := range clips {
clip, _ := node.AsClip()
media, _ := clip.MediaReference("")
external, _ := media.AsExternalReference()
url, _ := external.TargetURL()
name, _ := clip.Name()
fmt.Println(name, "->", url)
}
}import OpenTimelineIO
// A cut of two clips, as an .otio file would hold it.
let cut = """
{
"OTIO_SCHEMA": "Track.1",
"name": "V1",
"kind": "Video",
"children": [
{"OTIO_SCHEMA": "Clip.2", "name": "A"},
{"OTIO_SCHEMA": "Clip.2", "name": "B"}
]
}
"""
// A media linker is handed each clip as it is read, with the arguments the
// read was given, and answers with the media the clip should use. nil leaves
// the clip as it was.
try OTIO.registerMediaLinker("proxies") { clip, arguments in
let name = try clip.name()
let root = try arguments.getString("root")
return try ExternalReference(name: name, targetURL: "\(root)/\(name).mov")
}
// A hook script is handed the whole result, and answers with what the read
// goes on with: here the same object, stamped.
try OTIO.registerHookScript("stamp") { target, arguments in
let who = try arguments.getString("who")
if let named = target as? SerializableObjectWithMetadata {
try named.metadata.setString("read_by", value: who)
}
return target
}
try OTIO.attachHookScript("post_adapter_read", script: "stamp")
// The read names the linker, and carries both sets of arguments as JSON.
let track = try OTIO.readFromBytes(
.otioJSON, data: Array(cut.utf8),
options: ReadOptions(
mediaLinker: "proxies",
mediaLinkerArguments: #"{"root": "/proxies"}"#,
hookArguments: #"{"who": "the conform"}"#))
if let named = track as? SerializableObjectWithMetadata {
let who = try named.metadata.getString("read_by")
print("read by", who)
}
for case let clip as Clip in try track.findClips() {
if let media = try clip.mediaReference() as? ExternalReference {
let name = try clip.name()
let url = try media.targetURL()
print(name, "->", url)
}
}
// The registry is the whole process's, so a program that is done with its
// plugins says so.
_ = OTIO.detachHookScript("post_adapter_read", script: "stamp")
_ = OTIO.unregisterHookScript("stamp")
_ = OTIO.unregisterMediaLinker("proxies") <cstdint>
<iostream>
<optional>
<string>
<vector>
<opentimelineio/otio.hpp>
// A cut of two clips, as an .otio file would hold it.
const std::string cut = R"({
"OTIO_SCHEMA": "Track.1",
"name": "V1",
"kind": "Video",
"children": [
{"OTIO_SCHEMA": "Clip.2", "name": "A"},
{"OTIO_SCHEMA": "Clip.2", "name": "B"}
]
})";
int main() {
// A media linker is handed each clip as it is read, with the arguments
// the read was given, and answers with the media the clip should use.
// An empty optional leaves the clip as it was.
otio::register_media_linker(
"proxies",
[](const otio::Clip &clip, const otio::Metadata &arguments)
-> std::optional<otio::MediaReference> {
const std::string root = arguments.get_string("root");
return otio::ExternalReference::create(
clip.name(), root + "/" + clip.name() + ".mov");
});
// A hook script is handed the whole result, and answers with what the
// read goes on with: here the same object, stamped. An exception thrown
// from either fails the read with Status::PLUGIN_ERROR and its message.
otio::register_hook_script(
"stamp", [](const otio::SerializableObject &target, const otio::Metadata &arguments) {
otio::Metadata(target).set_string("read_by", arguments.get_string("who"));
return target;
});
otio::attach_hook_script("post_adapter_read", "stamp");
// The read names the linker, and carries both sets of arguments as JSON.
otio::ReadOptions options = otio::read_options_default();
options.media_linker = "proxies";
options.media_linker_arguments = R"({"root": "/proxies"})";
options.hook_arguments = R"({"who": "the conform"})";
const otio::SerializableObject track = otio::read_from_bytes(
otio::Format::OTIO_JSON, std::vector<std::uint8_t>(cut.begin(), cut.end()), options);
std::cout << "read by " << otio::Metadata(track).get_string("read_by") << "\n";
for (const otio::SerializableObject &node : track.find_clips()) {
const otio::Clip clip = *node.as<otio::Clip>();
const otio::ExternalReference media =
*clip.media_reference()->as<otio::ExternalReference>();
std::cout << clip.name() << " -> " << media.target_url() << "\n";
}
// The registry is the whole process's, so take them out again.
otio::detach_hook_script("post_adapter_read", "stamp");
otio::unregister_hook_script("stamp");
otio::unregister_media_linker("proxies");
return 0;
}const std = ("std");
const otio = ("otio");
// A cut of two clips, as an .otio file would hold it.
const cut =
\\{
\\ "OTIO_SCHEMA": "Track.1",
\\ "name": "V1",
\\ "kind": "Video",
\\ "children": [
\\ {"OTIO_SCHEMA": "Clip.2", "name": "A"},
\\ {"OTIO_SCHEMA": "Clip.2", "name": "B"}
\\ ]
\\}
;
// Zig has no closures, so a plugin is a function and a context it is handed
// on every call. Here the context carries the allocator.
const Plugins = struct {
allocator: std.mem.Allocator,
// A media linker is handed each clip as it is read, with the arguments
// the read was given, and answers with the media the clip should use,
// built in the clip's own document. Null leaves the clip as it was.
fn proxies(self: *const Plugins, clip: otio.Clip, arguments: otio.Metadata) !?otio.Node {
const name = try clip.name(self.allocator);
defer self.allocator.free(name);
const root = try arguments.getString(self.allocator, "root");
defer self.allocator.free(root);
const url = try std.fmt.allocPrintSentinel(self.allocator, "{s}/{s}.mov", .{ root, name }, 0);
defer self.allocator.free(url);
const proxy = try otio.ExternalReference.init(clip.node.doc.?, null, url);
return proxy.node;
}
// A hook script is handed the whole result, and answers with what the
// read goes on with: here the same object, stamped.
fn stamp(self: *const Plugins, target: otio.Node, arguments: otio.Metadata) !otio.Node {
const who = try arguments.getString(self.allocator, "who");
defer self.allocator.free(who);
const held = try self.allocator.dupeZ(u8, who);
defer self.allocator.free(held);
try target.metadata().setString("read_by", held);
return target;
}
};
pub fn main() !void {
const allocator = std.heap.smp_allocator;
const plugins: Plugins = .{ .allocator = allocator };
try otio.registerMediaLinker("proxies", &plugins, Plugins.proxies);
defer _ = otio.unregisterMediaLinker("proxies");
try otio.registerHookScript("stamp", &plugins, Plugins.stamp);
defer _ = otio.unregisterHookScript("stamp");
try otio.attachHookScript("post_adapter_read", "stamp");
defer _ = otio.detachHookScript("post_adapter_read", "stamp");
// The read names the linker, and carries both sets of arguments as JSON.
var options = otio.readOptionsDefault();
options.media_linker = "proxies";
options.media_linker_arguments = "{\"root\": \"/proxies\"}";
options.hook_arguments = "{\"who\": \"the conform\"}";
const document = try otio.Document.readFromBytes(.otio_json, cut, options);
defer document.deinit();
const track = (try document.root()) orelse return error.Empty;
const who = try track.metadata().getString(allocator, "read_by");
defer allocator.free(who);
std.debug.print("read by {s}\n", .{who});
const clips = try track.findClips(allocator);
defer allocator.free(clips);
for (clips) |node| {
const clip = node.asClip() orelse continue;
const media = (try clip.mediaReference(null)) orelse continue;
const external = media.asExternalReference() orelse continue;
const url = try external.targetUrl(allocator);
defer allocator.free(url);
const name = try clip.name(allocator);
defer allocator.free(name);
std.debug.print("{s} -> {s}\n", .{ name, url });
}
}
/* A cut of two clips, as an .otio file would hold it. */
static const char CUT[] =
"{\"OTIO_SCHEMA\": \"Track.1\", \"name\": \"V1\", \"kind\": \"Video\","
" \"children\": [{\"OTIO_SCHEMA\": \"Clip.2\", \"name\": \"A\"},"
" {\"OTIO_SCHEMA\": \"Clip.2\", \"name\": \"B\"}]}";
/* A media linker is handed each clip as it is read, and an object whose
* metadata is the arguments the read was given. It answers with the media
* the clip should use, made in the same document, or leaves out_result
* alone to leave the clip as it was. */
static OtioStatus link_proxy(void *context, OtioDocument *document, OtioNode clip,
OtioNode arguments, OtioNode *out_result,
char *message, size_t message_capacity) {
OtioBuffer name, root;
char url[512];
(void)context;
if (otio_node_name(document, clip, &name, NULL) != OTIO_STATUS_OK) {
return OTIO_STATUS_INVALID_ARGUMENT;
}
if (otio_metadata_get_string(document, arguments, "root", &root, NULL) != OTIO_STATUS_OK) {
/* Whatever goes in message is the failure the read reports. */
snprintf(message, message_capacity, "no root to link under");
otio_buffer_free(name);
return OTIO_STATUS_INVALID_ARGUMENT;
}
snprintf(url, sizeof url, "%s/%s.mov", root.data, name.data);
otio_buffer_free(root);
OtioStatus status = otio_external_reference_new(document, name.data, url, out_result, NULL);
otio_buffer_free(name);
return status;
}
/* A hook script is handed the whole result, and answers with what the read
* goes on with: here the same object, stamped. The context is whatever was
* registered with it, here the key to stamp under. */
static OtioStatus stamp(void *context, OtioDocument *document, OtioNode target,
OtioNode arguments, OtioNode *out_result,
char *message, size_t message_capacity) {
OtioBuffer who;
(void)message;
(void)message_capacity;
if (otio_metadata_get_string(document, arguments, "who", &who, NULL) != OTIO_STATUS_OK) {
return OTIO_STATUS_INVALID_ARGUMENT;
}
OtioStatus status = otio_metadata_set_string(document, target, (const char *)context, who.data, NULL);
otio_buffer_free(who);
*out_result = target;
return status;
}
int main(void) {
/* The last two arguments are a context handed to every call, and a
* function the library calls to release it when the plugin goes. */
otio_register_media_linker("proxies", link_proxy, NULL, NULL, NULL);
otio_register_hook_script("stamp", stamp, "read_by", NULL, NULL);
otio_attach_hook_script("post_adapter_read", "stamp", NULL);
/* The read names the linker, and carries both sets of arguments as JSON. */
OtioReadOptions options;
options.struct_size = sizeof options;
otio_read_options_init(&options);
options.media_linker = "proxies";
options.media_linker_arguments = "{\"root\": \"/proxies\"}";
options.hook_arguments = "{\"who\": \"the conform\"}";
OtioDocument *document = NULL;
OtioBuffer error;
if (otio_read_from_bytes(OTIO_FORMAT_OTIO_JSON, (const uint8_t *)CUT, strlen(CUT),
&options, &document, &error) != OTIO_STATUS_OK) {
fprintf(stderr, "%s\n", error.data);
otio_buffer_free(error);
return 1;
}
OtioNode track, clips[2], media;
OtioBuffer text;
size_t count = 0;
otio_document_root(document, &track, NULL);
otio_metadata_get_string(document, track, "read_by", &text, NULL);
printf("read by %s\n", text.data);
otio_buffer_free(text);
otio_node_find_clips(document, track, clips, 2, &count, NULL);
for (size_t i = 0; i < count; i++) {
OtioBuffer name;
otio_node_name(document, clips[i], &name, NULL);
otio_clip_media_reference(document, clips[i], NULL, &media, NULL);
otio_external_reference_target_url(document, media, &text, NULL);
printf("%s -> %s\n", name.data, text.data);
otio_buffer_free(name);
otio_buffer_free(text);
}
otio_document_free(document);
otio_detach_hook_script("post_adapter_read", "stamp");
otio_unregister_hook_script("stamp");
otio_unregister_media_linker("proxies");
return 0;
}using System;
using System.Text;
using OpenTimelineIO;
// A cut of two clips, as an .otio file would hold it.
const string cut = """
{
"OTIO_SCHEMA": "Track.1",
"name": "V1",
"kind": "Video",
"children": [
{"OTIO_SCHEMA": "Clip.2", "name": "A"},
{"OTIO_SCHEMA": "Clip.2", "name": "B"}
]
}
""";
// A media linker is handed each clip as it is read, with the arguments
// the read was given, and answers with the media the clip should use.
// Answering null leaves the clip as it was.
Otio.RegisterMediaLinker("proxies", (clip, arguments) =>
{
var root = arguments.GetString("root");
return new ExternalReference(clip.Name(), $"{root}/{clip.Name()}.mov");
});
// A hook script is handed the whole result, and answers with what the
// read goes on with: here the same object, stamped.
Otio.RegisterHookScript("stamp", (target, arguments) =>
{
var stamped = (SerializableObjectWithMetadata)target;
stamped.Metadata.SetString("read_by", arguments.GetString("who"));
return target;
});
Otio.AttachHookScript("post_adapter_read", "stamp");
try
{
// The read names the linker, and carries both sets of arguments as JSON.
var track = (Track)Otio.ReadFromBytes(
Format.OtioJson,
Encoding.UTF8.GetBytes(cut),
new ReadOptions(
mediaLinker: "proxies",
mediaLinkerArguments: """{"root": "/proxies"}""",
hookArguments: """{"who": "the conform"}"""));
Console.WriteLine($"read by {track.Metadata.GetString("read_by")}");
foreach (var node in track.FindClips())
{
if (node is Clip clip && clip.MediaReference(null) is ExternalReference media)
{
Console.WriteLine($"{clip.Name()} -> {media.TargetUrl()}");
}
}
}
finally
{
Otio.DetachHookScript("post_adapter_read", "stamp");
Otio.UnregisterHookScript("stamp");
Otio.UnregisterMediaLinker("proxies");
}
// A cut of two clips, as an .otio file would hold it.
static NSString *const cut = @"{"
@"\"OTIO_SCHEMA\": \"Track.1\", \"name\": \"V1\", \"kind\": \"Video\","
@"\"children\": ["
@"{\"OTIO_SCHEMA\": \"Clip.2\", \"name\": \"A\"},"
@"{\"OTIO_SCHEMA\": \"Clip.2\", \"name\": \"B\"}"
@"]}";
// A media linker is handed each clip as it is read, with the arguments the
// read was given, and answers with the media the clip should use. nil, with
// no error, leaves the clip as it was.
ProxyLinker : NSObject <OTIOMediaLinker>
ProxyLinker
- (nullable OTIOMediaReference *)linkMediaReferenceForClip:(OTIOClip *)clip
arguments:(OTIOMetadata *)arguments
error:(NSError **)error {
NSString *name = [clip name:error];
NSString *root = [arguments getString:@"root" error:error];
if (name == nil || root == nil) {
return nil;
}
NSString *url = [NSString stringWithFormat:@"%@/%@.mov", root, name];
return [OTIOExternalReference externalReferenceWithName:name targetURL:url error:error];
}
// A hook script is handed the whole result, and answers with what the read
// goes on with: here the same object, stamped.
StampScript : NSObject <OTIOHookScript>
StampScript
- (nullable OTIOSerializableObject *)runHookOnObject:(OTIOSerializableObject *)target
arguments:(OTIOMetadata *)arguments
error:(NSError **)error {
NSString *who = [arguments getString:@"who" error:error];
OTIOMetadata *metadata = [(OTIOSerializableObjectWithMetadata *)target metadata];
if (who == nil || ![metadata setString:@"read_by" value:who error:error]) {
return nil;
}
return target;
}
int main(void) {
{
NSError *error = nil;
// The library keeps each plugin until it is unregistered. On Apple's
// platforms OTIORegisterMediaLinkerUsingBlock takes a block instead.
if (!OTIORegisterMediaLinker(@"proxies", [ProxyLinker new], &error)
|| !OTIORegisterHookScript(@"stamp", [StampScript new], &error)
|| !OTIOAttachHookScript(@"post_adapter_read", @"stamp", &error)) {
NSLog(@"%@", error.localizedDescription);
return 1;
}
// The read names the linker, and carries both sets of arguments as
// JSON.
OTIOReadOptions options = OTIOReadOptionsDefault();
options.mediaLinker = @"proxies";
options.mediaLinkerArguments = @"{\"root\": \"/proxies\"}";
options.hookArguments = @"{\"who\": \"the conform\"}";
NSData *data = [cut dataUsingEncoding:NSUTF8StringEncoding];
OTIOSerializableObject *track =
OTIOReadFromBytes(OTIOFormatOTIOJSON, data, &options, &error);
if (track == nil) {
NSLog(@"%@", error.localizedDescription);
return 1;
}
OTIOMetadata *metadata = [(OTIOTrack *)track metadata];
printf("read by %s\n", [metadata getString:@"read_by" error:&error].UTF8String);
for (OTIOSerializableObject *clip in [track findClips:&error]) {
OTIOSerializableObject *media = [(OTIOClip *)clip mediaReference:nil error:&error];
NSString *url = [(OTIOExternalReference *)media targetURL:&error];
printf("%s -> %s\n", [clip name:&error].UTF8String, url.UTF8String);
}
OTIODetachHookScript(@"post_adapter_read", @"stamp");
OTIOUnregisterHookScript(@"stamp");
OTIOUnregisterMediaLinker(@"proxies");
}
return 0;
}Where they run
A read parses the file, then runs, in this order, which is upstream's:
| Step | Handed |
|---|---|
post_adapter_read hook | the hook arguments, plus adapter_arguments and media_linker_argument_map |
| the media linker, once per clip | the media linker arguments |
post_media_linker hook | the media linker arguments, not the hook ones, as upstream hands them |
A write runs pre_adapter_write before it writes and post_adapter_write
after. Writing to a file adds the path as _filepath. The pre-write hook
works on a copy, so the object you asked to write is left exactly as it was,
and what the hook returns is what gets written.
Any other hook name is one of your own. Attaching a script to it declares it,
and run_hook on an object runs it there and then.
Choosing a linker
A read with no linker named uses the one the OTIO_DEFAULT_MEDIA_LINKER
environment variable names, or none, as upstream does. Naming one that was
never registered fails the read rather than skipping the step, because a
conform that silently did not link is worse than one that stopped. Asking for
no linking at all (do_not_link_media, upstream's
MediaLinkingPolicy.DoNotLinkMedia) leaves every clip alone, and the hooks
still run.
Arguments
Each read or write can carry two argument maps: one for the linker and one for the hooks. Outside Rust and Python they cross as JSON text, an object whose values may be anything OTIO metadata can hold, timeline objects included. Your function receives them as metadata it can read with the metadata calls it already knows. A timeline object among the arguments is moved into the document the plugin works on; one the plugin puts into the timeline stays there, and the rest are removed once it has run. Arguments that are not a JSON object fail the read or write, whether or not any plugin is registered to take them.
Failures
A linker or script that fails stops the read or write, and the error carries
its own message, prefixed with the plugin's name. In every language a
failure is whatever that language fails with, an error, an exception, a
panic, and it is turned into the library's PLUGIN_ERROR at the boundary
rather than crossing into code that cannot handle it.
Registering
Plugins live in one registry per process, as upstream's do. Registering a name again replaces what it named; unregistering says whether there was anything to remove. A script that is unregistered while a hook still lists it fails that hook when it next runs, as upstream fails one its manifest names and cannot find, so detach it as well.
Python finds plugins the way upstream does, through plugin manifests that
name .py modules, and also sees every linker and script registered from any
other language in the same process. Every other language registers functions
in code, which is how a program in that language would load them anyway.
ADR 0008
and ADR 0009
say why.