AAF
Reading, writing and changing AAF files, the options each takes, and how a written file relinks in Media Composer.
AAF is how a cut reaches Avid Media Composer. Reading and writing are ports of upstream's AAF adapter, checked byte for byte against what upstream reads and writes, and AAF is available from every language.
Reading an AAF
An AAF reads as upstream's adapter reads it, byte for byte once written as OTIO JSON, on every sample file in upstream's test data. Upstream's four reading options are all here, with upstream's defaults:
simplify(on) collapses the nesting AAF has and OTIO does not need.attach_markers(on) moves each marker from the slot that carries it onto the item it points at.bake_keyframed_properties(off) records each keyframed effect parameter's value at every frame of its effect askeyframe_baked_values, interpolated the way pyaaf2 interpolates it.transcribe_log(off) prints a line for each thing the reader makes, word for word what upstream prints. From Rust it takes aTranscribeLog, which hands each line to a function of your choosing. It is not in the C ABI or the SDKs built on it, since it would need a callback across it.
From C and the SDKs, the first two are spelled as what turning them off
does, aaf_keep_nesting and aaf_markers_on_slots, so that options left at
zero read an AAF the way upstream does. The third is aaf_bake_keyframes.
Baked curves go through the platform's pow, acos and cos, so off Linux
the last digit of a baked value can differ from upstream's. Three log lines
upstream prints for rare failures embed a Python object's memory address or
a whole track, and those are worded differently here.
Writing an AAF
AAF is how a cut reaches Avid Media Composer, and writing one is a port of upstream's AAF adapter rather than a design of its own. Each track becomes a slot of a composition. Each clip becomes a chain of three objects: a master clip, a file describing the media, and a tape carrying its timecode, which is the chain Media Composer expects to relink through. Gaps, dissolves, nested tracks, stacks and markers all have an AAF form; any other transition is left out, as upstream leaves it out.
use otio_aaf::{Aaf, WriteOptions};
use otio_adapter::Adapter;
fn main() -> Result<(), otio_adapter::Error> {
// AAF is written from a timeline. A cut that was read from an AAF keeps
// what the reader found under metadata["AAF"], and that is written back,
// so its clips point at the same master mobs and the same media.
let document = otio_core::from_str(&std::fs::read_to_string("cut.otio")?)?;
// Every clip needs a MobID, from its metadata, its media's metadata or
// the AAF its media names. A cut built from scratch has none, so let
// the writer make them up rather than refuse the clip.
let options = WriteOptions::new().with_use_empty_mob_ids(true);
Aaf::write_to_file(&document, "cut.aaf", &options)?;
Ok(())
}import opentimelineio as otio
# An AAF clip is cut from media of a known length, so each clip's media says
# how much of it there is. A cut read from an AAF already carries that, and
# what the reader found under metadata["AAF"], and both are written back.
timeline = otio.schema.Timeline(name="Cut")
track = otio.schema.Track(name="V1", kind="Video")
timeline.tracks.append(track)
for name in ["A001C003", "A001C004"]:
one_second = otio.opentime.TimeRange(
start_time=otio.opentime.RationalTime(0, 24),
duration=otio.opentime.RationalTime(24, 24),
)
track.append(otio.schema.Clip(
name=name,
media_reference=otio.schema.ExternalReference(
target_url=f"file:///media/{name}.mov",
available_range=one_second,
),
source_range=one_second,
))
# Every clip needs a MobID, from its metadata, its media's metadata or the
# AAF its media names. A cut built from scratch has none, so let the writer
# make them up rather than refuse the clip.
otio.adapters.write_to_file(timeline, "cut.aaf", use_empty_mob_ids=True)
print([clip.name for clip in otio.adapters.read_from_file("cut.aaf").find_clips()])import { writeFile } from "node:fs/promises";
import {
init,
Clip,
ExternalReference,
RationalTime,
Timeline,
TimeRange,
Track,
readFromBytes,
writeToBytes,
} from "@alchemist-edit/otio";
await init();
const timeline = new Timeline({ name: "Cut" });
const track = new Track({ name: "V1", kind: "Video" });
timeline.tracks?.appendChild(track);
const oneSecond = new TimeRange(new RationalTime(0, 24), new RationalTime(24, 24));
// An AAF clip is cut from media of a known length, so each clip's media says
// how much of it there is. A new clip has no media at all, so its reference
// goes in under upstream's key and is made the active one.
for (const name of ["A001C003", "A001C004"]) {
const media = new ExternalReference({ targetUrl: `file:///media/${name}.mov` });
media.availableRange = oneSecond;
const clip = new Clip({ name });
clip.setMediaReference("DEFAULT_MEDIA", media);
clip.activeMediaReferenceKey = "DEFAULT_MEDIA";
clip.sourceRange = oneSecond;
track.appendChild(clip);
}
// Every clip needs a MobID, from its metadata, its media's metadata or the
// AAF its media names. A cut built from scratch has none, so let the writer
// make them up rather than refuse the clip. The module has no clock or
// randomness of its own, so this side supplies the time and a seed.
const aaf = writeToBytes("aaf", timeline, { aafUseEmptyMobIds: true });
await writeFile("cut.aaf", aaf);
for (const clip of readFromBytes("aaf", aaf).findClips()) {
console.log(clip.name);
}package main
import (
"fmt"
"log"
otio "github.com/alchemist-editor/otio-rust/sdk/go"
)
func main() {
timeline, err := otio.NewTimeline("Cut")
if err != nil {
log.Fatal(err)
}
defer timeline.Close()
stack, err := otio.NewStack("tracks")
if err != nil {
log.Fatal(err)
}
track, err := otio.NewTrack("V1", "Video")
if err != nil {
log.Fatal(err)
}
if err := timeline.SetTracks(&stack.Node); err != nil {
log.Fatal(err)
}
if err := stack.AppendChild(track.Node); err != nil {
log.Fatal(err)
}
oneSecond := otio.TimeRange{
StartTime: otio.RationalTime{Value: 0, Rate: 24},
Duration: otio.RationalTime{Value: 24, Rate: 24},
}
// An AAF clip is cut from media of a known length, so each clip's media
// says how much of it there is. A new clip has no media at all, so its
// reference goes in under upstream's key and is made the active one.
for _, name := range []string{"A001C003", "A001C004"} {
media, err := otio.NewExternalReference("", "file:///media/"+name+".mov")
if err != nil {
log.Fatal(err)
}
if err := media.SetAvailableRange(oneSecond); err != nil {
log.Fatal(err)
}
clip, err := otio.NewClip(name)
if err != nil {
log.Fatal(err)
}
if err := clip.SetMediaReference("DEFAULT_MEDIA", media.Node); err != nil {
log.Fatal(err)
}
if err := clip.SetActiveMediaReferenceKey("DEFAULT_MEDIA"); err != nil {
log.Fatal(err)
}
if err := clip.SetSourceRange(oneSecond); err != nil {
log.Fatal(err)
}
if err := track.AppendChild(clip.Node); err != nil {
log.Fatal(err)
}
}
// Every clip needs a MobID, from its metadata, its media's metadata or
// the AAF its media names. A cut built from scratch has none, so let the
// writer make them up rather than refuse the clip.
options := &otio.WriteOptions{AAFUseEmptyMobIds: true}
if err := otio.WriteToFile(otio.FormatAAF, timeline.Node, "cut.aaf", options); err != nil {
log.Fatal(err)
}
root, err := otio.ReadFromFile(otio.FormatAAF, "cut.aaf", nil)
if err != nil {
log.Fatal(err)
}
defer root.Close()
clips, err := root.FindClips()
if err != nil {
log.Fatal(err)
}
for _, clip := range otio.Filter(clips, otio.Node.AsClip) {
name, _ := clip.Name()
fmt.Println(name)
}
}import OpenTimelineIO
let timeline = try Timeline(name: "Cut")
let stack = try Stack(name: "tracks")
let track = try Track(name: "V1", kind: "Video")
try timeline.setTracks(stack)
try stack.appendChild(track)
let oneSecond = TimeRange(
startTime: RationalTime(value: 0, rate: 24),
duration: RationalTime(value: 24, rate: 24))
// An AAF clip is cut from media of a known length, so each clip's media says
// how much of it there is. A new clip has no media at all, so its reference
// goes in under upstream's key and is made the active one.
for name in ["A001C003", "A001C004"] {
let media = try ExternalReference(targetURL: "file:///media/\(name).mov")
try media.setAvailableRange(oneSecond)
let clip = try Clip(name: name)
try clip.setMediaReference("DEFAULT_MEDIA", reference: media)
try clip.setActiveMediaReferenceKey("DEFAULT_MEDIA")
try clip.setSourceRange(oneSecond)
try track.appendChild(clip)
}
// Every clip needs a MobID, from its metadata, its media's metadata or the
// AAF its media names. A cut built from scratch has none, so let the writer
// make them up rather than refuse the clip.
try OTIO.writeToFile(
.aaf, root: timeline, path: "cut.aaf", options: WriteOptions(aafUseEmptyMobIds: true))
for case let clip as Clip in try OTIO.readFromFile(.aaf, path: "cut.aaf").findClips() {
print(try clip.name())
} <iostream>
<opentimelineio/otio.hpp>
int main() {
otio::Timeline timeline = otio::Timeline::create("Cut");
otio::Stack stack = otio::Stack::create("tracks");
otio::Track track = otio::Track::create("V1", "Video");
timeline.set_tracks(stack);
stack.append_child(track);
const otio::TimeRange one_second(otio::RationalTime(0, 24), otio::RationalTime(24, 24));
// An AAF clip is cut from media of a known length, so each clip's media
// says how much of it there is. A new clip has no media at all, so its
// reference goes in under upstream's key and is made the active one.
const std::vector<std::string> names = {"A001C003", "A001C004"};
for (const std::string &name : names) {
otio::ExternalReference media =
otio::ExternalReference::create(std::nullopt, "file:///media/" + name + ".mov");
media.set_available_range(one_second);
otio::Clip clip = otio::Clip::create(name);
clip.set_media_reference("DEFAULT_MEDIA", media);
clip.set_active_media_reference_key("DEFAULT_MEDIA");
clip.set_source_range(one_second);
track.append_child(clip);
}
// Every clip needs a MobID, from its metadata, its media's metadata or
// the AAF its media names. A cut built from scratch has none, so let the
// writer make them up rather than refuse the clip.
otio::WriteOptions options = otio::write_options_default();
options.aaf_use_empty_mob_ids = true;
otio::write_to_file(otio::Format::AAF, timeline, "cut.aaf", options);
const otio::SerializableObject root =
otio::read_from_file(otio::Format::AAF, "cut.aaf", std::nullopt);
for (const otio::SerializableObject &node : root.find_clips()) {
if (std::optional<otio::Clip> clip = node.as<otio::Clip>()) {
std::cout << clip->name() << "\n";
}
}
return 0;
}const std = ("std");
const otio = ("otio");
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
const document = try otio.Document.init();
defer document.deinit();
const timeline = try otio.Timeline.init(document, "Cut");
const stack = try otio.Stack.init(document, "tracks");
const track = try otio.Track.init(document, "V1", "Video");
try timeline.setTracks(stack.asNode());
try stack.appendChild(track.asNode());
const one_second = otio.TimeRange{
.start_time = .{ .value = 0, .rate = 24 },
.duration = .{ .value = 24, .rate = 24 },
};
// An AAF clip is cut from media of a known length, so each clip's media
// says how much of it there is. A new clip has no media at all, so its
// reference goes in under upstream's key and is made the active one.
inline for (.{ "A001C003", "A001C004" }) |name| {
const url = "file:///media/" ++ name ++ ".mov";
const media = try otio.ExternalReference.init(document, null, url);
try media.setAvailableRange(one_second);
const clip = try otio.Clip.init(document, name);
try clip.setMediaReference("DEFAULT_MEDIA", media.asNode());
try clip.setActiveMediaReferenceKey("DEFAULT_MEDIA");
try clip.setSourceRange(one_second);
try track.appendChild(clip.asNode());
}
try document.setRoot(timeline.asNode());
// Every clip needs a MobID, from its metadata, its media's metadata or
// the AAF its media names. A cut built from scratch has none, so let the
// writer make them up rather than refuse the clip. Every other option
// keeps its default.
try document.writeToFile(.aaf, "cut.aaf", .{ .aaf_use_empty_mob_ids = true });
const written = try otio.Document.readFromFile(.aaf, "cut.aaf", null);
defer written.deinit();
const root = (try written.root()) orelse return error.Empty;
for (try root.findClips(allocator)) |node| {
if (node.asClip()) |clip| {
std.debug.print("{s}\n", .{try clip.name(allocator)});
}
}
}
int main(void) {
OtioDocument *document = otio_document_new();
OtioNode timeline, stack, track;
otio_timeline_new(document, "Cut", &timeline, NULL);
otio_stack_new(document, "tracks", &stack, NULL);
otio_track_new(document, "V1", "Video", &track, NULL);
otio_timeline_set_tracks(document, timeline, stack, NULL);
otio_composition_append_child(document, stack, track, NULL);
OtioTimeRange one_second;
one_second.start_time.value = 0.0;
one_second.start_time.rate = 24.0;
one_second.duration.value = 24.0;
one_second.duration.rate = 24.0;
/* An AAF clip is cut from media of a known length, so each clip's media
* says how much of it there is. A new clip has no media at all, so its
* reference goes in under upstream's key and is made the active one. */
const char *names[] = {"A001C003", "A001C004"};
for (size_t index = 0; index < 2; index += 1) {
char url[64];
snprintf(url, sizeof url, "file:///media/%s.mov", names[index]);
OtioNode reference;
otio_external_reference_new(document, NULL, url, &reference, NULL);
otio_media_reference_set_available_range(document, reference, one_second, NULL);
OtioNode clip;
otio_clip_new(document, names[index], &clip, NULL);
otio_clip_set_media_reference(document, clip, "DEFAULT_MEDIA", reference, NULL);
otio_clip_set_active_media_reference_key(document, clip, "DEFAULT_MEDIA", NULL);
otio_item_set_source_range(document, clip, one_second, NULL);
otio_composition_append_child(document, track, clip, NULL);
}
otio_document_set_root(document, timeline, NULL);
/* Every clip needs a MobID, from its metadata, its media's metadata or
* the AAF its media names. A cut built from scratch has none, so let the
* writer make them up rather than refuse the clip. */
OtioWriteOptions options;
options.struct_size = sizeof options;
otio_write_options_init(&options);
options.aaf_use_empty_mob_ids = true;
OtioBuffer error;
if (otio_write_to_file(OTIO_FORMAT_AAF, document, "cut.aaf", &options, &error) != OTIO_STATUS_OK) {
fprintf(stderr, "%s\n", error.data);
otio_buffer_free(error);
return 1;
}
otio_document_free(document);
OtioDocument *written = NULL;
if (otio_read_from_file(OTIO_FORMAT_AAF, "cut.aaf", NULL, &written, &error) != OTIO_STATUS_OK) {
fprintf(stderr, "%s\n", error.data);
otio_buffer_free(error);
return 1;
}
OtioNode root;
otio_document_root(written, &root, NULL);
size_t count = 0;
otio_node_find_clips(written, root, NULL, 0, &count, NULL);
OtioNode *clips = malloc(count * sizeof *clips);
otio_node_find_clips(written, root, clips, count, &count, NULL);
for (size_t index = 0; index < count; index += 1) {
OtioBuffer name;
if (otio_node_name(written, clips[index], &name, NULL) == OTIO_STATUS_OK) {
printf("%s\n", name.data);
otio_buffer_free(name);
}
}
free(clips);
otio_document_free(written);
return 0;
}using System;
using OpenTimelineIO;
var timeline = new Timeline("Cut");
var stack = new Stack("tracks");
var track = new Track("V1", "Video");
timeline.SetTracks(stack);
stack.AppendChild(track);
var oneSecond = new TimeRange(new RationalTime(0, 24), new RationalTime(24, 24));
// An AAF clip is cut from media of a known length, so each clip's media says
// how much of it there is. A new clip has no media at all, so its reference
// goes in under upstream's key and is made the active one.
foreach (var name in new[] { "A001C003", "A001C004" })
{
var media = new ExternalReference(targetUrl: $"file:///media/{name}.mov");
media.SetAvailableRange(oneSecond);
var clip = new Clip(name);
clip.SetMediaReference("DEFAULT_MEDIA", media);
clip.SetActiveMediaReferenceKey("DEFAULT_MEDIA");
clip.SetSourceRange(oneSecond);
track.AppendChild(clip);
}
// Every clip needs a MobID, from its metadata, its media's metadata or the
// AAF its media names. A cut built from scratch has none, so let the writer
// make them up rather than refuse the clip.
Otio.WriteToFile(Format.Aaf, timeline, "cut.aaf", new WriteOptions(aafUseEmptyMobIds: true));
foreach (var node in Otio.ReadFromFile(Format.Aaf, "cut.aaf", null).FindClips())
{
if (node is Clip clip)
{
Console.WriteLine(clip.Name());
}
}
int main(void) {
{
NSError *error = nil;
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];
OTIOTimeRange oneSecond =
OTIOTimeRangeMake(OTIORationalTimeMake(0, 24), OTIORationalTimeMake(24, 24));
// An AAF clip is cut from media of a known length, so each clip's
// media says how much of it there is. A new clip has no media at
// all, so its reference goes in under upstream's key and is made
// the active one.
for (NSString *name in @[ @"A001C003", @"A001C004" ]) {
NSString *url = [NSString stringWithFormat:@"file:///media/%@.mov", name];
OTIOExternalReference *media =
[OTIOExternalReference externalReferenceWithName:nil targetURL:url error:&error];
[media setAvailableRange:oneSecond error:&error];
OTIOClip *clip = [OTIOClip clipWithName:name error:&error];
[clip setMediaReference:@"DEFAULT_MEDIA" reference:media error:&error];
[clip setActiveMediaReferenceKey:@"DEFAULT_MEDIA" error:&error];
[clip setSourceRange:oneSecond error:&error];
[track appendChild:clip error:&error];
}
// Every clip needs a MobID, from its metadata, its media's metadata
// or the AAF its media names. A cut built from scratch has none, so
// let the writer make them up rather than refuse the clip.
OTIOWriteOptions options = OTIOWriteOptionsDefault();
options.aafUseEmptyMobIds = YES;
if (!OTIOWriteToFile(OTIOFormatAAF, timeline, @"cut.aaf", &options, &error)) {
NSLog(@"%@", error.localizedDescription);
return 1;
}
OTIOSerializableObject *root = OTIOReadFromFile(OTIOFormatAAF, @"cut.aaf", NULL, &error);
for (OTIOSerializableObject *node in [root findClips:&error]) {
if ([node isKindOfClass:[OTIOClip class]]) {
NSLog(@"%@", [(OTIOClip *)node name:&error]);
}
}
}
return 0;
}A clip is tied to its media by a MobID. A cut read from an AAF already has
one on every clip, kept under metadata["AAF"], and it is written back, so
the file relinks to the media it came from. A clip whose media is itself an
AAF holding one master clip takes that clip's MobID. Anything else, such as
a cut built from scratch, has none, and writing it needs
use_empty_mob_ids, which makes MobIDs up. A made-up MobID links to no
media Media Composer knows, which is why upstream refuses a clip without one
by default, and so does this.
Before writing anything, the writer checks the whole timeline the way upstream does: every item at the timeline's rate, every clip saying how much media it has, and every transition carrying what an AAF dissolve is built from. It reports everything it finds wrong at once rather than stopping at the first.
The file is the same, byte for byte, as the one upstream's adapter writes from the same timeline, given the same clock and the same random identifiers. The tests check that on twelve files, and it holds on all 33 samples in upstream's own test data that upstream can write. That parity is the evidence the file suits Media Composer: none of the files has been imported into Media Composer as part of testing.
Embedding the media in the file works as upstream's embed_essence does.
Each clip's media URL is taken as a path, relative to the working directory
unless it is absolute. An .aaf there has the master mob with the clip's
MobID copied out of it, with its source mob and essence. A .dnx on a video
track is imported as a raw DNxHD stream. Anything else is refused as
upstream refuses it, and that includes a WAV file, which upstream sends to
the DNxHD import too.
From Python, upstream's four AAF hooks run where upstream runs them:
otio_aaf_pre_read_transcribe and otio_aaf_post_read_transcribe before and
after the file is transcribed, and otio_aaf_pre_write_transcribe and
otio_aaf_post_write_transcribe before and after it is written. A
pre_write hook is where to transcode other media into something
embed_essence can embed, as upstream suggests: what it returns is what is
written. Each hook is handed upstream's arguments, except that aaf_handle,
upstream's open pyaaf2 file, is None, because there is no pyaaf2 file here.
From Rust, ReadOptions::with_post_transcribe runs a callback at the one
point inside the read; the other three run around the read or write, so a
caller runs them itself.
AAF writing is available from every language. From Python,
otio.adapters.write_to_file(timeline, "cut.aaf") takes upstream's keyword
arguments, embed_essence=True among them. From C and the SDKs, the same options are aaf_-prefixed fields of the write
options, along with three a library caller needs and a Python one does not:
aaf_user, whom a new marker is credited to when no login name is set;
aaf_time, the time the file records; and aaf_id_seed, which seeds the
identifiers it makes up. The same time, seed and timeline write the same
file. WebAssembly has no clock or randomness of its own, so the TypeScript
package passes the time and a fresh seed on every write unless you give
your own. Nor has it a file system, so it cannot embed media: with
aafEmbedEssence set, a clip whose media names a file stops the write, as
the file cannot be found.
Changing an existing AAF
Below the adapter, the aaf crate can open an AAF file that already exists,
change it and save it, as pyaaf2 does when it opens a file with 'r+'. The
same edits in the same order leave the same bytes pyaaf2 leaves, which
twenty scenarios in its tests check against files pyaaf2 changed: properties
changed, added and removed, mobs and slots added and taken out, new
definitions and classes, and essence moved and dropped. It works on the AAF
object model, not on a timeline, and is available from Rust only. The
crate's README
has an example, and
ADR 0005
the design.