Package Exports
- @chatoctopus/timeline
Readme
@chatoctopus/timeline
Import and export video editing timelines for Final Cut Pro, Adobe Premiere Pro, DaVinci Resolve, and OpenTimelineIO.
Generates well-formed FCPXML 1.8 (Final Cut Pro), FCP7 XML / xmeml v5 (Premiere, Resolve), and OTIO (OpenTimelineIO) with frame-accurate rational time math -- no floating-point drift.
Installation
npm install @chatoctopus/timelineRequires Node.js >= 18. For buildTimeline() auto-probing, FFmpeg/FFprobe must be installed and on your PATH. Converting between formats does not require FFmpeg/FFprobe.
CLI
The package ships with a timeline CLI focused on format conversion and validation:
npx @chatoctopus/timeline convert ./edit.fcpxml --to otio --out ./edit.otio
npx @chatoctopus/timeline validate ./edit.xml
npx @chatoctopus/timeline validate ./edit.otio --jsonCommands
| Command | Description |
|---|---|
convert <input> --to <fcpx|premiere|resolve|otio> [--out <path>] |
Auto-detect input format, convert to target editor format, and write to file (--out) or stdout |
validate <input> [--json] |
Validate timeline integrity and frame alignment; exits with non-zero on hard errors |
Quick Start
Import from an existing project file
Auto-detects FCPXML, xmeml, or OTIO format.
import { importTimeline, exportTimeline } from "@chatoctopus/timeline"
import { readFileSync, writeFileSync } from "fs"
// Read a Final Cut Pro project
const fcpxml = readFileSync("project.fcpxml", "utf-8")
const { timeline, warnings } = importTimeline(fcpxml)
console.log(`Imported "${timeline.name}" with ${timeline.tracks.length} tracks`)
if (warnings.length > 0) console.warn("Warnings:", warnings)
// Convert to Premiere Pro format
writeFileSync("project.xml", exportTimeline(timeline, "premiere"))Convert between formats
import { importTimeline, exportTimeline } from "@chatoctopus/timeline"
import { readFileSync, writeFileSync } from "fs"
// Premiere XML -> Final Cut Pro
const premiereXml = readFileSync("edit.xml", "utf-8")
const { timeline } = importTimeline(premiereXml)
writeFileSync("edit.fcpxml", exportTimeline(timeline, "fcpx"))
// Final Cut Pro -> DaVinci Resolve
const fcpxml = readFileSync("edit.fcpxml", "utf-8")
const { timeline: tl } = importTimeline(fcpxml)
writeFileSync("edit-resolve.xml", exportTimeline(tl, "resolve"))
// OTIO -> Final Cut Pro
const otio = readFileSync("project.otio", "utf-8")
const { timeline: tl2 } = importTimeline(otio)
writeFileSync("project.fcpxml", exportTimeline(tl2, "fcpx"))
// Any format -> OTIO
const anyFile = readFileSync("timeline.fcpxml", "utf-8")
const { timeline: tl3 } = importTimeline(anyFile)
writeFileSync("timeline.otio", exportTimeline(tl3, "otio"))Build a timeline from video files
The simplest path: provide file paths and optional trim points. Metadata is extracted automatically via FFprobe.
buildTimeline() validates trim inputs strictly: startAt and duration must be finite, non-negative numbers, 0 is treated as an explicit value, and explicit durations may be rejected for mixed-frame-rate sources when they cannot be represented consistently.
import { buildTimeline, exportTimeline } from "@chatoctopus/timeline"
import { writeFileSync } from "fs"
const timeline = await buildTimeline("Wedding Highlights", [
{ path: "/footage/ceremony.mp4", startAt: 30, duration: 10 },
{ path: "/footage/reception.mp4", duration: 15 },
{ path: "/footage/speeches.mp4", startAt: 120, duration: 20 },
])
// Final Cut Pro
writeFileSync("wedding.fcpxml", exportTimeline(timeline, "fcpx"))
// Adobe Premiere Pro
writeFileSync("wedding.xml", exportTimeline(timeline, "premiere"))
// DaVinci Resolve
writeFileSync("wedding.xml", exportTimeline(timeline, "resolve"))
// OpenTimelineIO (universal interchange)
writeFileSync("wedding.otio", exportTimeline(timeline, "otio"))Construct a timeline manually
For full control, build the NLETimeline object directly. All timing uses Rational numbers ({ num, den }) to stay frame-aligned.
import {
exportTimeline,
rational,
ZERO,
FRAME_RATES,
} from "@chatoctopus/timeline"
import type { NLETimeline } from "@chatoctopus/timeline"
import { writeFileSync } from "fs"
const timeline: NLETimeline = {
name: "My Edit",
format: {
width: 1920,
height: 1080,
frameRate: FRAME_RATES["29.97"], // { num: 30000, den: 1001 }
audioRate: 48000,
colorSpace: "1-1-1 (Rec. 709)",
},
assets: [
{
id: "r2",
name: "interview.mp4",
path: "/footage/interview.mp4",
duration: rational(9000 * 1001, 30000), // 9000 frames at 29.97fps
hasVideo: true,
hasAudio: true,
audioChannels: 2,
audioRate: 48000,
timecodeStart: ZERO,
},
],
tracks: [
{
type: "video",
clips: [
{
assetId: "r2",
name: "interview",
offset: ZERO, // starts at timeline 0
duration: rational(150 * 1001, 30000), // 150 frames = ~5 seconds
sourceIn: rational(300 * 1001, 30000), // start from frame 300 in source
sourceDuration: rational(150 * 1001, 30000),
audioRole: "dialogue",
},
],
},
],
}
writeFileSync("output.fcpxml", exportTimeline(timeline, "fcpx"))API Reference
High-Level Functions
| Function | Description |
|---|---|
exportTimeline(timeline, editor, options?) |
Export an NLETimeline. editor is "fcpx", "premiere", "resolve", or "otio". |
importTimeline(content) |
Parse FCPXML, xmeml, or OTIO into an NLETimeline. Auto-detects format. |
buildTimeline(name, clips) |
Build an NLETimeline from ClipInput[] by probing files with FFprobe. |
Format-Specific Functions
| Function | Description |
|---|---|
writeFCPXML(timeline, options?) |
Generate FCPXML 1.8 string |
readFCPXML(xmlString) |
Parse FCPXML into NLETimeline |
writeXMEML(timeline, options?) |
Generate xmeml v5 string |
readXMEML(xmlString) |
Parse xmeml into NLETimeline |
writeOTIO(timeline) |
Generate OTIO JSON string |
readOTIO(jsonString) |
Parse OTIO JSON into NLETimeline |
Time Utilities
All timing uses Rational ({ num: number, den: number }) to avoid floating-point drift.
| Function | Description |
|---|---|
rational(num, den) |
Create a simplified rational number |
add(a, b) |
Add two rationals |
subtract(a, b) |
Subtract (clamps to zero) |
toSeconds(r) |
Convert rational to float seconds |
toFCPString(r) |
Format as FCP time string ("1001/24000s") |
parseFCPString(s) |
Parse FCP time string back to rational |
secondsToFrameAligned(secs, frameRate) |
Convert seconds, snapped to nearest frame boundary |
toFrames(duration, frameDuration) |
Convert rational to frame count |
parseTimecode(tc, frameRate) |
Parse SMPTE timecode ("01:00:00;00") with drop-frame support |
FRAME_RATES |
Common presets: "23.976", "24", "25", "29.97", "30", "59.94", "60" |
Validation
| Function | Description |
|---|---|
validateTimeline(timeline) |
Returns array of ValidationError (checks asset refs, frame alignment, dimensions) |
hasErrors(results) |
true if any hard errors (not just warnings) |
computeTimelineDuration(timeline) |
Compute total duration from all track clips |
Probing
| Function | Description |
|---|---|
probeAsset(filePath) |
Run FFprobe on a file and return a fully populated NLEAsset |
Types
interface NLETimeline {
name: string
format: NLEFormat
tracks: NLETrack[]
assets: NLEAsset[]
}
interface NLEFormat {
width: number
height: number
frameRate: Rational // e.g. { num: 30000, den: 1001 } for 29.97fps
audioRate: number // e.g. 48000
colorSpace?: string
}
interface NLETrack {
type: "video" | "audio"
clips: NLEClip[]
}
interface NLEClip {
assetId: string // references NLEAsset.id
name: string
offset: Rational // position on timeline
duration: Rational // clip duration on timeline
sourceIn: Rational // in-point within source media
sourceDuration: Rational
lane?: number
audioRole?: string
volume?: number
enabled?: boolean
}
interface NLEAsset {
id: string
name: string
path: string // absolute file path
duration: Rational
hasVideo: boolean
hasAudio: boolean
videoFormat?: NLEFormat
audioChannels?: number
audioRate?: number
timecodeStart?: Rational
}
interface ClipInput {
path: string // absolute file path
startAt?: number // trim start in seconds
duration?: number // clip length in seconds
}
type NLEEditor = "fcpx" | "premiere" | "resolve" | "otio"Supported Formats
| Format | Extension | Editors / Tools | Read | Write |
|---|---|---|---|---|
| FCPXML 1.8 | .fcpxml |
Final Cut Pro | Yes | Yes |
| xmeml v5 | .xml |
Adobe Premiere Pro, DaVinci Resolve | Yes | Yes |
| OpenTimelineIO | .otio |
Resolve 18+, Hiero, rv, and OTIO ecosystem | Yes | Yes |
Verification
Run the test suite:
npm testRun tests with coverage:
npm run test:coverageType-check without emitting:
npm run lintBuild:
npm run buildQuick smoke test
node --input-type=module -e "
import { exportTimeline, rational, ZERO, FRAME_RATES } from './dist/index.js';
const timeline = {
name: 'Smoke Test',
format: {
width: 1920, height: 1080,
frameRate: FRAME_RATES['29.97'],
audioRate: 48000,
},
assets: [{
id: 'r2', name: 'clip.mp4', path: '/tmp/clip.mp4',
duration: rational(300 * 1001, 30000),
hasVideo: true, hasAudio: true,
audioChannels: 2, audioRate: 48000, timecodeStart: ZERO,
}],
tracks: [{
type: 'video',
clips: [{
assetId: 'r2', name: 'clip', offset: ZERO,
duration: rational(150 * 1001, 30000),
sourceIn: ZERO,
sourceDuration: rational(150 * 1001, 30000),
}],
}],
};
const fcpxml = exportTimeline(timeline, 'fcpx');
const xmeml = exportTimeline(timeline, 'premiere');
const otio = exportTimeline(timeline, 'otio');
console.log('FCPXML:', fcpxml.includes('<fcpxml') ? 'OK' : 'FAIL');
console.log('xmeml:', xmeml.includes('<xmeml') ? 'OK' : 'FAIL');
console.log('OTIO:', otio.includes('Timeline.1') ? 'OK' : 'FAIL');
console.log('Done.');
"Architecture
src/
├── index.ts Public API: exportTimeline, importTimeline, buildTimeline
├── types.ts NLETimeline, NLEClip, NLEAsset, NLETrack, NLEFormat, Rational
├── time.ts Rational arithmetic, frame alignment, SMPTE timecode parsing
├── probe.ts FFprobe metadata extraction
├── validate.ts Pre-export validation (reference integrity, frame alignment)
├── fcpxml/
│ ├── writer.ts FCPXML 1.8 generation
│ └── reader.ts FCPXML parsing
├── xmeml/
│ ├── writer.ts xmeml v5 generation (Premiere / Resolve)
│ └── reader.ts xmeml parsing
└── otio/
├── writer.ts OpenTimelineIO JSON generation
└── reader.ts OpenTimelineIO JSON parsingHow It Works
Rational time math is the core of the library. All NLE software uses frame-aligned timing internally -- expressing durations as rational fractions like 1001/30000s (one frame at 29.97fps). Using floating-point seconds causes frame drift and "not on edit frame boundary" errors in Final Cut Pro.
Every clip duration and offset goes through secondsToFrameAligned() which snaps to the nearest frame boundary, matching the behavior of both buttercut (Ruby) and cutlass (Go) which this library draws from.
Three interchange formats cover all major editors and tools:
- FCPXML 1.8 for Final Cut Pro -- trackless magnetic timeline with
<asset-clip>elements inside a<spine> - xmeml v5 for Premiere and Resolve -- track-based with linked
<clipitem>elements for video and audio - OpenTimelineIO (
.otio) -- the industry-standard JSON interchange format backed by the Academy Software Foundation. OTIO acts as a universal hub: any tool that speaks OTIO gets instant access to timelines from any other format. Transitions and effects in OTIO files are noted as warnings during import (the core clip/track/timing data is fully preserved).
Acknowledgments
This project draws on ideas and timing behavior from buttercut and cutlass, and we gratefully acknowledge those projects as upstream inspiration.
Trademarks
Final Cut Pro is a trademark of Apple Inc. Adobe Premiere Pro is a trademark of Adobe. DaVinci Resolve is a trademark of Blackmagic Design Pty Ltd. All other product names, logos, and brands are the property of their respective owners.
License
MIT