EDL (CMX 3600)
Reading and writing Edit Decision Lists: the rate you must supply, the dialects, and what writing refuses.
An Edit Decision List is the oldest interchange format in post and the most
widely understood: a title, then a numbered list of events, each one or two
lines of timecode with free-form comments beneath. A file reads as a
Timeline.
use otio_adapter::Adapter;
use otio_cmx3600::{Cmx3600, ReadOptions};
fn main() -> Result<(), otio_adapter::Error> {
// 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.
let options = ReadOptions {
rate: 24.0,
..Default::default()
};
let document = Cmx3600::read_from_file("cut.edl", &options)?;
let root = document.root().expect("a parsed document has a root");
for id in document.find_clips(root)? {
let clip = document.try_get(id)?;
println!("{}", clip.name());
}
Ok(())
}import opentimelineio as otio
# 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. The adapter is picked from the suffix, as upstream picks it.
timeline = otio.adapters.read_from_file("cut.edl", rate=24)
for clip in timeline.find_clips():
print(clip.name)import { readFile } from "node:fs/promises";
import { init, readTimelineFromString } from "@alchemist-edit/otio";
await init();
// There is no filesystem behind a WebAssembly module, so getting hold of the
// bytes is the host's line of code: `fs` in Node, `fetch` in a browser.
const edl = await readFile("cut.edl", "utf8");
// 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.
const timeline = readTimelineFromString("cmx3600", edl, { rate: 24 });
for (const clip of timeline.findClips()) {
console.log(clip.name);
}package main
import (
"fmt"
"log"
otio "github.com/alchemist-editor/otio-rust/sdk/go"
)
func main() {
// 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.
//
// Reading answers with what the file was about — the root object — and
// there is no container to hold on to besides it.
root, err := otio.ReadFromFile(otio.FormatCMX3600, "cut.edl", &otio.ReadOptions{Rate: 24})
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
// 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.
let options = ReadOptions(rate: 24, nameColumn: "", ignoreTimecodeMismatch: false)
// Reading hands back what the file is about, not a container to look inside.
let root = try OTIO.readFromFile(.cmx3600, path: "cut.edl", options: options)
for case let clip as Clip in try root.findClips() {
print(try clip.name())
} <iostream>
<opentimelineio/otio.hpp>
int main() {
// 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.
otio::ReadOptions options = otio::read_options_default();
options.rate = 24;
// Reading hands back the file's root object; there is no container
// around it to hold.
const otio::SerializableObject root =
otio::read_from_file(otio::Format::CMX_3600, "cut.edl", options);
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();
// 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.
const options = otio.ReadOptions{
.rate = 24,
.name_column = null,
.ignore_timecode_mismatch = false,
};
const document = try otio.Document.readFromFile(.cmx3600, "cut.edl", options);
defer document.deinit();
const root = (try document.root()) orelse return error.Empty;
// Anything the library hands back that has to be freed is copied into an
// allocator you pass, and freed with `allocator.free`.
const clips = try root.findClips(allocator);
defer allocator.free(clips);
for (clips) |node| {
if (node.asClip()) |clip| {
const name = try clip.name(allocator);
defer allocator.free(name);
std.debug.print("{s}\n", .{name});
}
}
}
int main(void) {
/* 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;
options.struct_size = sizeof options;
otio_read_options_init(&options);
options.rate = 24;
/* A call that can fail says why in the buffer passed last, which is
* yours to free. Pass NULL where the status is all you want. */
OtioDocument *document = NULL;
OtioBuffer error;
if (otio_read_from_file(OTIO_FORMAT_CMX_3600, "cut.edl", &options, &document, &error) != OTIO_STATUS_OK) {
fprintf(stderr, "%s\n", error.data);
otio_buffer_free(error);
return 1;
}
OtioNode root;
otio_document_root(document, &root, NULL);
/* A list call is asked twice: once with no buffer, to learn the count,
* and once to fill one. */
size_t count = 0;
otio_node_find_clips(document, root, NULL, 0, &count, NULL);
OtioNode *clips = malloc(count * sizeof *clips);
otio_node_find_clips(document, root, clips, count, &count, NULL);
for (size_t index = 0; index < count; index += 1) {
OtioBuffer name;
if (otio_node_name(document, clips[index], &name, NULL) == OTIO_STATUS_OK) {
printf("%s\n", name.data);
otio_buffer_free(name);
}
}
free(clips);
otio_document_free(document);
return 0;
}using System;
using OpenTimelineIO;
// 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.
// A value here is a `readonly struct`, as one in .NET should be, so the
// rate is chosen when the options are made rather than set afterwards.
var defaults = Otio.ReadOptionsDefault();
var options = new ReadOptions(24, defaults.NameColumn, defaults.IgnoreTimecodeMismatch);
// Reading hands back the object the file is about, which for an EDL is
// the timeline it describes.
var timeline = Otio.ReadFromFile(Format.Cmx3600, "cut.edl", options);
foreach (var node in timeline.FindClips())
{
if (node is Clip clip)
{
Console.WriteLine(clip.Name());
}
}
int main(void) {
{
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;
}An EDL does not know its own rate
This is the one thing to get right. A CMX 3600 file is timecode and nothing else: it never states the rate that timecode is at, and nothing can infer it from the contents. The rate you pass is believed. Pass the wrong one and the file still reads — every event simply lands somewhere it should not.
There is a usual guess, 24, and it is a guess rather than a safe default.
What becomes what
| EDL | Document |
|---|---|
TITLE: | the timeline's name |
each channel the events name (V, A1, AA/V) | a track |
| an event | a clip on every track its channel maps to |
| a hole in the record timecode | a gap |
D and W### edits | a Transition |
* FROM CLIP NAME: | the clip's name |
* FROM CLIP: / * FROM FILE: / * OTIO REFERENCE FROM: | the clip's media |
a path with a [1001-1020] range in it | an ImageSequenceReference |
* LOC: | a Marker, with its colour |
*ASC_SOP and *ASC_SAT | the clip's metadata["cdl"] |
M2 | a LinearTimeWarp |
| a freeze-frame comment | a FreezeFrame |
BL, BLACK, BARS reels | a GeneratorReference |
| everything else | the clip's metadata["cmx_3600"] |
Keeping the unrecognized comments is what lets a file survive a round trip: reading each of upstream's samples, writing it back and reading it again gives the same timeline.
Dialects
The three systems that read EDLs disagree about the comment that names a clip's media, so the writer takes a style:
| Style | Names media with |
|---|---|
| Avid (the default) | * FROM CLIP: |
| Nucoda | * FROM FILE: |
| Premiere | nothing, with the path in * OTIO REFERENCE FROM: |
Premiere reads any FROM comment as meaning the clip has no name and calls it
UNKNOWN, so that dialect writes AX as every reel and puts the path in a
comment Premiere ignores and this adapter can read back. From Rust the style
is an enum, so an unknown dialect cannot be spelled at all; from Python it is
upstream's style keyword argument.
Reel names
Most systems will only read a reel name of eight characters, so the writer
pads or truncates to that by default. A truncated name is recorded in an
* OTIO TRUNCATED REEL NAME FROM: comment, so reading the file back gets the
original. Asking for no limit writes the name in full, which loses nothing
but which most systems will not read.
What writing will not do
Writing is narrower than reading, as it is upstream:
- Only one enabled video track, and at most two audio tracks. An EDL describes a single strand of picture, so a timeline with two video tracks has no EDL form and is refused.
- Dissolves, but not wipes. A wipe reads, and writes back out as a dissolve.
- One timing effect per clip, and only a speed change or a freeze frame.
Where it differs from upstream
The adapter is a port of upstream's otio-cmx3600-adapter and reproduces its
behaviour, with two differences:
- The writer works on a copy of the document, so writing does not change what you handed it. Upstream rewrites the timeline in place, and a caller who writes twice gets different text the second time.
- A marker's colour is a real colour here rather than the string
"RED", so the name is canonicalized to"Red"and given its components. The name the file used is still on the marker'smetadata["cmx_3600"].
The crate's README has the Rust options in full.