O
All pages

Objective-C

A Cocoa library over libotio, on Apple's runtime and GNUstep's, with a class per schema, values as C structs and failure as an NSError.

The Objective-C SDK links libotio, the C ABI built from the Rust core, and runs on Apple's runtime and on GNUstep's. It is generated from that ABI by otio-sdk-gen, so it carries the whole data model: the schemas, the composition algorithms, the ten edit operations and the file-format adapters. What things are called follows upstream OpenTimelineIO; what the binding is follows Cocoa.

#import <OpenTimelineIO/OpenTimelineIO.h>

Install

The Rust library it calls into is built by cargo and copied into sdk/objc/lib/; it is not checked in:

cargo build -p otio-capi --release
cp target/release/libotio.a sdk/objc/lib/

Then, from sdk/objc:

make          # build/libOpenTimelineIO.a
make check    # build and run the tests

On Linux this needs GNUstep's base library and a clang that can build Objective-C: gnustep-devel and clang on Debian and Ubuntu. On macOS it needs nothing but Xcode's command line tools.

Each GitHub release also carries the Objective-C SDK's sources, laid out as in the repository with an empty lib/, and a prebuilt libotio-<version>-<target>.tar.gz per target whose lib/ holds the library to put there.

Your first program

#import <Foundation/Foundation.h>

#import <OpenTimelineIO/OpenTimelineIO.h>

int main(void) {
    @autoreleasepool {
        NSError *error = nil;

        // An EDL never says what rate its timecode is at, so this has to be
        // right: a file read at the wrong rate puts every event in the wrong
        // place rather than failing.
        OTIOReadOptions options = OTIOReadOptionsDefault();
        options.rate = 24;

        // Reading hands back the object the file is about, which for an EDL
        // is the timeline it describes.
        OTIOSerializableObject *timeline = OTIOReadFromFile(OTIOFormatCMX3600,
                                                            @"cut.edl",
                                                            &options,
                                                            &error);
        if (timeline == nil) {
            NSLog(@"%@", error);
            return 1;
        }

        for (OTIOSerializableObject *node in [timeline findClips:&error]) {
            // Every object arrives as the class its schema names, so this is
            // an ordinary Cocoa class test rather than a cast.
            if ([node isKindOfClass:[OTIOClip class]]) {
                NSLog(@"%@", [node name:&error]);
            }
        }
    }
    return 0;
}

Reading hands back the object the file is about, which for an EDL is the timeline it describes. OTIOOpen infers the format from the filename instead of taking one, and OTIOSave writes back the same way. The rate is the one option an EDL cannot do without; see CMX 3600 EDL.

Every handle the library hands back arrives as the class its schema names, so isKindOfClass: tells the truth.

Building a timeline

#import <Foundation/Foundation.h>

#import <OpenTimelineIO/OpenTimelineIO.h>

int main(void) {
    @autoreleasepool {
        NSError *error = nil;

        // Each object is made on its own and joins a timeline when you put
        // it into one. Nothing has to exist before the thing it goes into,
        // so these are ordinary Cocoa class factories.
        OTIOTimeline *timeline = [OTIOTimeline timelineWithName:@"Cut" error:&error];
        OTIOStack *stack = [OTIOStack stackWithName:@"tracks" error:&error];
        OTIOTrack *track = [OTIOTrack trackWithName:@"V1" kind:@"Video" error:&error];

        [timeline setTracks:stack error:&error];
        [stack appendChild:track error:&error];

        NSArray<NSString *> *names = @[ @"A", @"B", @"C" ];
        for (NSUInteger index = 0; index < names.count; index++) {
            OTIOClip *clip = [OTIOClip clipWithName:[names objectAtIndex:index] error:&error];
            OTIORationalTime start = OTIORationalTimeMake((double)index * 24, 24);
            OTIOTimeRange span = OTIOTimeRangeMake(start, OTIORationalTimeMake(24, 24));
            [clip setSourceRange:span error:&error];
            [track appendChild:clip error:&error];
        }

        // Three seconds of picture, written as canonical OpenTimelineIO JSON.
        // The objects keep their timeline alive between them, so there is
        // nothing to close.
        OTIORationalTime duration;
        if ([track getDuration:&duration error:&error]) {
            NSLog(@"%f", OTIORationalTimeToSeconds(duration));
        }
        OTIOSave(timeline, @"cut.otio", &error);
    }
    return 0;
}

Each object is made on its own with an ordinary class factory, such as clipWithName:error:, and joins a timeline when you put it into one. Values are C structs, as NSRange and CGRect are, with OTIORationalTimeMake and OTIOTimeRangeMake to build one and C functions such as OTIORationalTimeToSeconds to compute with one.

How objects and documents work here

There is no document in the surface. Underneath, the core keeps a timeline's objects in an arena and an object is an index into one; this SDK does that bookkeeping. The objects hold the arena strongly between them, so it outlives every handle into it and goes when the last object naming it does.

An object that has not joined anything is a timeline of one. Putting it into another moves it there, and an object from a timeline it was never put into is refused rather than quietly dragged along with everything around it.

-[OTIOSerializableObject close] is there for releasing a large timeline at a moment you chose. Every object that lived in it fails with OTIOStatusNullPointer afterwards rather than reading freed memory.

The sources build both with ARC and with manual retain and release, since everything the SDK owns is confined to the runtime.

Errors and missing values

Failure is an NSError out-parameter in the OTIOErrorDomain, whose code is the OTIOStatus. A call that can fail answers NO or nil.

  • "There is nothing here" is a failure you can tell apart. It fails with OTIOStatusNoValue, which OTIOIsNoValue(error) recognises.
  • So is an object from another timeline. A call that only names an object refuses one from elsewhere before asking the library, with OTIOStatusInvalidArgument, and OTIOIsOtherTimeline(error) recognises it.
  • An object that already has a parent is refused before it moves. Appending or inserting one that is still a child in another timeline fails as the library would fail it, with OTIOStatusCoreError and its message, and placing one whose handle has gone stale fails with OTIOStatusStaleHandle, but before that timeline is brought over, so both stay whole.

Editing and algorithms

#import <Foundation/Foundation.h>

#import <OpenTimelineIO/OpenTimelineIO.h>

/// One second of picture, named.
static OTIOClip *Second(NSString *name) {
    NSError *error = nil;
    OTIOClip *clip = [OTIOClip clipWithName:name error:&error];
    [clip setSourceRange:OTIOTimeRangeMake(OTIORationalTimeMake(0, 24),
                                           OTIORationalTimeMake(24, 24))
                   error:&error];
    return clip;
}

static void Show(OTIOTrack *track) {
    NSError *error = nil;
    NSMutableArray<NSString *> *names = [NSMutableArray array];
    for (OTIOSerializableObject *child in [track children:&error]) {
        [names addObject:[child name:&error]];
    }

    OTIORationalTime duration;
    [track getDuration:&duration error:&error];
    NSLog(@"%@ - %g frames", [names componentsJoinedByString:@" "], duration.value);
}

int main(void) {
    @autoreleasepool {
        NSError *error = nil;

        OTIOTrack *track = [OTIOTrack trackWithName:@"V1" kind:@"Video" error:&error];
        for (NSString *name in @[ @"A", @"B", @"C" ]) {
            [track appendChild:Second(name) error:&error];
        }
        Show(track);

        // Insert makes room: everything from the insertion point onwards
        // moves later, and the track gets longer.
        OTIOInsert(Second(@"D"), track, OTIORationalTimeMake(24, 24), NO, nil, &error);
        Show(track);

        // Overwrite does not: it lays an item over a span and whatever was
        // in that span gives way. The track is the same length afterwards.
        OTIOOverwrite(Second(@"E"), track,
                      OTIOTimeRangeMake(OTIORationalTimeMake(48, 24),
                                        OTIORationalTimeMake(24, 24)),
                      NO, nil, &error);
        Show(track);
    }
    return 0;
}

The ten edit operations and the algorithms are C functions, such as OTIOInsert, OTIOOverwrite and OTIOFlattenTracks, each taking an NSError ** last. Editing a timeline says what each edit does.

Formats

OTIOReadFromFile and OTIOWriteToFile take an OTIOFormat: OTIOFormatOTIOJSON, OTIOFormatALE, OTIOFormatCMX3600, OTIOFormatFcp7XML, OTIOFormatFcpxXML, OTIOFormatAAF, and the two bundles, OTIOFormatOTIOZ and OTIOFormatOTIOD, which are read and written through a path only.

#import <Foundation/Foundation.h>

#import <OpenTimelineIO/OpenTimelineIO.h>

int main(void) {
    @autoreleasepool {
        NSError *error = nil;

        // An EDL never says what rate its timecode is at, so this has to be
        // right: a file read at the wrong rate puts every event in the wrong
        // place rather than failing.
        OTIOReadOptions options = OTIOReadOptionsDefault();
        options.rate = 24;

        OTIOSerializableObject *timeline = OTIOReadFromFile(OTIOFormatCMX3600,
                                                            @"cut.edl",
                                                            &options,
                                                            &error);
        if (timeline == nil) {
            NSLog(@"%@", error);
            return 1;
        }

        // Nothing happens in between. The timeline an EDL parses to is the
        // same timeline FCP X writes out, so converting is a read and a
        // write: the object model is the interchange, and the file formats
        // are two ways of spelling it.
        if (!OTIOWriteToFile(OTIOFormatFcpxXML, timeline, @"cut.fcpxml", NULL, &error)) {
            NSLog(@"%@", error);
            return 1;
        }
    }
    return 0;
}

See Reading and writing files, AAF and Bundles.

Media linkers and hooks

#import <Foundation/Foundation.h>

#import <OpenTimelineIO/OpenTimelineIO.h>

// 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.
@interface ProxyLinker : NSObject <OTIOMediaLinker>
@end

@implementation 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];
}
@end

// A hook script is handed the whole result, and answers with what the read
// goes on with: here the same object, stamped.
@interface StampScript : NSObject <OTIOHookScript>
@end

@implementation 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;
}
@end

int main(void) {
    @autoreleasepool {
        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;
}

A media linker is an object answering OTIOMediaLinker, and a hook script one answering OTIOHookScript. OTIORegisterMediaLinker and OTIORegisterHookScript register one under a name, and the library keeps it until OTIOUnregisterMediaLinker or OTIOUnregisterHookScript lets it go or the name is registered again. A read names its linker in OTIOReadOptions.mediaLinker, and OTIOAttachHookScript attaches a script to one of the four hooks every read and write runs, or to one of your own, which -runHook:arguments:error: runs. Both are upstream's plugin points, with upstream's arguments, handed over as OTIOMetadata.

Where the compiler has blocks, which is always on Apple's platforms, OTIORegisterMediaLinkerUsingBlock and OTIORegisterHookScriptUsingBlock take a block instead. GNUstep's legacy runtime has no blocks, so the protocols are the way in that works everywhere.

  • Failing is answering nil and setting the error. A linker that answers nil and sets nothing leaves the clip as it is; a hook script must answer an object to go on with. The read or write fails with OTIOStatusPluginError and the plugin's own message.
  • An exception never reaches the library. One raised in a plugin, or any object thrown, is caught where the library called it and becomes that failure.
  • What a plugin is handed is lent for the call. It may change it, and may answer an object built fresh, which joins the lent timeline. Moving an object of that timeline into another one fails, closing it waits until the call is over, and an object of it kept past the call fails with OTIOStatusNullPointer, so nothing frees what the library still holds.

Platforms

CI builds and tests the SDK on Linux, with GNUstep, and on macOS, with Apple's runtime.

Reference