davinci-resolve-mcp 2.58.0 → 2.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,49 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.59.0
6
+
7
+ First-class conform ingest for **AAF** and **DRP**, an offline **Premiere `.prproj`** reader with
8
+ a conform bridge, and a unified sequence enumerator — so an editorial turnover in any of these
9
+ formats can be previewed, picked, and brought into Resolve. Everything keeps the honest-refuse
10
+ philosophy: no format is ever faked; an unreadable file yields a clear, actionable error.
11
+
12
+ ### Added
13
+
14
+ - **AAF** — offline preview via the advanced server's `editorial.parse_interchange` (format `aaf`)
15
+ and `list_sequences`, backed by the pure-Python `aaf2` (pyaaf2) reader (shelled out from Node;
16
+ honest-refuses with an install/convert hint when unavailable). Live AAF import now works through
17
+ `timeline.import_timeline_checked` — the XML sanitize pass is skipped for the binary format and,
18
+ when `relink_search_roots` is passed, a post-import media-pool relink runs (fuzzy-relink parity
19
+ via `RelinkClips`), reported in a `relink` block.
20
+ - **DRP** — `drt.list_sequences` enumerates the timelines inside a `.drp`/`.drt`
21
+ (`[{id, name, eventCount}]`) to drive a picker; `timeline.import_from_drp` extracts the chosen
22
+ timelines (offline zip surgery → temp `.drt`) and imports each into a running Resolve.
23
+ - **Premiere `.prproj`** — a from-scratch offline reader (gunzip + object-reference-graph walk, no
24
+ new dependencies) exposed through `parse_interchange` / `list_sequences`. Derives cuts, source
25
+ in/out, timeline positions, speed/retime, reverse, transitions, markers, and media paths. Effects
26
+ and Lumetri color are not translated (the Premiere→Resolve semantic gap, flagged not faked).
27
+ - **Conform bridge** — `editorial.convert_to_interchange` authors OTIO / EDL / DRT that Resolve
28
+ imports, from normalized events or a parsed source. This lets a `.prproj` be conformed into
29
+ Resolve with no Premiere in the loop.
30
+ - **Unified enumeration** — `editorial.list_sequences(path)` is one picker entry point across
31
+ xml/edl/otio/drt/drp/aaf/prproj.
32
+
33
+ ### Dependencies
34
+
35
+ - New optional-but-default `requirements.txt` pins `pyaaf2` (pure-Python, MIT, ~1 MB) for the
36
+ offline AAF reader; the installer points the advanced server's `AAF_PROBE_PYTHON` at the project
37
+ venv so it works out of the box. Without it, AAF preview honest-refuses; nothing else needs it.
38
+
39
+ ### Validation
40
+
41
+ - Static checks, drift guards, and focused unit tests pass (advanced Node suite 455 pass / 9 skip;
42
+ new `test_import_from_drp.py` and `prproj-bridge.test.mjs` / `aaf-sequences.test.mjs`). Offline
43
+ AAF parse validated end-to-end against a real `aaf2`-authored AAF.
44
+ - **Not yet live-validated in Resolve**: AAF import, the post-import relink, `import_from_drp`, and
45
+ importing an authored OTIO/DRT from a real `.prproj`. These paths are offline-tested (fake Resolve)
46
+ and guarded; confirm against a live session with disposable projects before relying on them.
47
+
5
48
  ## What's New in v2.58.0
6
49
 
7
50
  Major expansion of the optional **advanced** Node server (`davinci-resolve-advanced-mcp`,
package/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.58.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.59.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
7
+ [![Advanced](https://img.shields.io/badge/Advanced%20(offline)-18%20tools-blueviolet.svg)](#server-modes)
7
8
  [![Tested](https://img.shields.io/badge/Live%20Tested-98.5%25-green.svg)](docs/reference/api-coverage.md#test-results)
8
9
  [![DaVinci Resolve](https://img.shields.io/badge/DaVinci%20Resolve-18.5+-darkred.svg)](https://www.blackmagicdesign.com/products/davinciresolve)
9
10
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
@@ -171,7 +172,8 @@ The default server is a local stdio process launched by your MCP client; it does
171
172
 
172
173
  | Metric | Value |
173
174
  |--------|-------|
174
- | MCP Tools | **32** compound / **341** granular |
175
+ | MCP Tools | **34** compound / **341** granular (live server) |
176
+ | Advanced (offline) tools | **18** — .drp/.drt/.drx + DB authoring, no Resolve running |
175
177
  | Kernel Actions | **136** guarded workflow actions across 9 compound tools |
176
178
  | API Methods Covered | **336/336** (100%) |
177
179
  | Methods Live Tested | **331/336** (98.5%) |
@@ -234,12 +236,3 @@ Samuel Gursky (samgursky@gmail.com)
234
236
 
235
237
  - Blackmagic Design for DaVinci Resolve and its scripting API
236
238
  - The Model Context Protocol team for enabling AI assistant integration
237
- - Anthropic for Claude Code, used extensively in development and testing
238
-
239
- ### Community contributions
240
-
241
- - [@rgxdev](https://github.com/rgxdev) — configurable Media Pool inventory walk
242
- (exclude bins + inventory limit), [#69](https://github.com/samuelgursky/davinci-resolve-mcp/pull/69) (v2.53.0)
243
- - [@swayll](https://github.com/swayll) — Source Track Selector investigation
244
- ([#74](https://github.com/samuelgursky/davinci-resolve-mcp/issues/74)); confirmed
245
- + documented API limitation (v2.57.1)
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.58.0"
38
+ VERSION = "2.59.0"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
@@ -666,17 +666,25 @@ def write_client_config(client, python_path, server_path, api_path, lib_path, dr
666
666
  return True, str(config_path)
667
667
 
668
668
 
669
- def build_advanced_entry(server_path):
669
+ def build_advanced_entry(server_path, python_path=None):
670
670
  """Build the config entry for the optional 'davinci-resolve-advanced' server.
671
671
 
672
672
  This is the Node, beyond-the-scripting-API sibling (file/.drp/.drt/.drx
673
673
  authoring + conform + editorial, no live Resolve required). It ships in the
674
674
  same package as a second bin. Opt-in: include it only if you want the
675
675
  file-level tools alongside the live server.
676
+
677
+ The Node server shells out to Python for the OFFLINE AAF reader (aaf_probe.py,
678
+ pyaaf2). We pin AAF_PROBE_PYTHON to the project venv's interpreter — the same
679
+ venv install.py installs pyaaf2 into — so AAF preview works out of the box
680
+ instead of depending on whatever `python3` happens to be on PATH.
676
681
  """
677
682
  project_dir = Path(server_path).resolve().parents[1] # .../src/server.py -> repo root
678
683
  advanced_bin = project_dir / "bin" / "davinci-resolve-advanced-mcp.mjs"
679
- return {"command": "node", "args": [str(advanced_bin)]}
684
+ entry = {"command": "node", "args": [str(advanced_bin)]}
685
+ if python_path:
686
+ entry["env"] = {"AAF_PROBE_PYTHON": str(python_path)}
687
+ return entry
680
688
 
681
689
 
682
690
  def generate_manual_config(python_path, server_path, api_path, lib_path):
@@ -684,7 +692,7 @@ def generate_manual_config(python_path, server_path, api_path, lib_path):
684
692
  entry = build_server_entry(python_path, server_path, api_path, lib_path)
685
693
  zed_entry = build_zed_entry(python_path, server_path, api_path, lib_path)
686
694
  opencode_entry = build_opencode_entry(python_path, server_path, api_path, lib_path)
687
- advanced = build_advanced_entry(server_path)
695
+ advanced = build_advanced_entry(server_path, python_path)
688
696
 
689
697
  # Standard snippet includes BOTH servers: the live Python server and the
690
698
  # optional Node 'advanced' server (file-level, no Resolve). Drop the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.58.0",
3
+ "version": "2.59.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -91,7 +91,11 @@ Each dispatches on an `action`. Highlights:
91
91
  - **`media`** — media front-end / AE: `ingest_verify` (hash seal/verify/dupes-by-hash), `media_inventory`
92
92
  (fps/codec/colorspace/TC + card gaps), `sync` (TC picture↔sound + drift/MOS), `relink_manifest`,
93
93
  `rename_plan` (refuses camera originals) / `reel_normalize`, `turnover_package`, `project_hygiene`.
94
- - **`editorial`** — editorial integrity: `parse_interchange` (EDL/OTIO/XMEML; AAF = honest refuse),
94
+ - **`editorial`** — editorial integrity: `parse_interchange` (EDL/OTIO/XMEML natively, AAF via pyaaf2,
95
+ **.prproj via gunzip+XML** — pass the file PATH for the binary ones), `list_sequences` (one picker entry
96
+ point across xml/edl/otio/drt/drp/aaf/prproj), `convert_to_interchange` (author OTIO/EDL/DRT that Resolve
97
+ imports, from events or a parsed source — **the .prproj→Resolve conform bridge**, no Premiere needed;
98
+ editorial timing/cuts/transitions/speed carry, per-clip effects/color do not),
95
99
  `turnover_changelist` (moved/retimed/replaced/new/gone + timing silent-lie guards), `conform_manifest`,
96
100
  `marker_roundtrip`.
97
101
  - **`provenance`** — provenance / audit: `gallery_lineage`, `grade_provenance` ("why is this graded this
@@ -161,6 +165,7 @@ status + install hints):
161
165
  | grading/QC catalog, `conform.verify` (frame compare) | `sharp` (optional) | native module; `npm i sharp` |
162
166
  | `pipeline` (project DB), `fairlight` live-DB path, conform lineage/reverse | `better-sqlite3` (optional) | native module; `npm i better-sqlite3`. (The `.drp`-zip Fairlight path needs none.) |
163
167
  | YAML authoring (`pipeline compile` from a YAML dir) | `js-yaml` (optional) | pure JS; `npm i js-yaml` |
168
+ | `editorial` AAF offline preview (`parse_interchange` format `aaf`, `list_sequences` on `.aaf`) | **Python 3 + `pyaaf2`** | AAF is binary; no trustworthy pure-JS reader. The server shells out to `aaf_probe.py` (pure-Python `aaf2`). `pip install pyaaf2`. Without it, AAF **honest-refuses** with a convert-upstream hint (EDL/OTIO/`.drt` need nothing). Live AAF import is on the Python `davinci-resolve` MCP (Resolve reads AAF natively). |
164
169
 
165
170
  Missing features fail with a clear, actionable message rather than crashing; the server logs a one-line
166
171
  "needs setup" summary to stderr at startup.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * AAF (.aaf) offline reader — the honest bridge for `parse_interchange` / `list_sequences`.
3
+ *
4
+ * AAF is a binary Structured-Storage container; no pure-JS reader is trustworthy, so we shell
5
+ * out to `aaf_probe.py` (pure-Python `aaf2`/pyaaf2). This keeps the honest-refuse philosophy:
6
+ * - real parse when pyaaf2 is available,
7
+ * - a CLEAR, actionable error otherwise (never a fake/empty parse).
8
+ *
9
+ * The live import path does NOT use this — Resolve reads AAF natively via
10
+ * timeline.import_timeline_checked. This is only for the offline picker+preview.
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import { existsSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import path from 'node:path';
16
+
17
+ const PROBE = fileURLToPath(new URL('./aaf_probe.py', import.meta.url));
18
+
19
+ /** python interpreter — overridable for environments/tests (must have `aaf2` importable). */
20
+ function pythonCmd() {
21
+ return process.env.AAF_PROBE_PYTHON || process.env.PYTHON || 'python3';
22
+ }
23
+
24
+ const REMEDIATION =
25
+ 'Install the offline AAF reader (`pip install pyaaf2`) to preview AAF without Resolve, ' +
26
+ 'OR convert to OTIO/EDL/FCP7-XML upstream, OR import it live via timeline.import_timeline_checked ' +
27
+ '(Resolve reads AAF natively).';
28
+
29
+ function runProbe(aafPath) {
30
+ return new Promise((resolve, reject) => {
31
+ let child;
32
+ try {
33
+ child = spawn(pythonCmd(), [PROBE, aafPath]);
34
+ } catch (e) {
35
+ reject(new Error(`AAF offline preview needs Python + pyaaf2, but Python could not start (${e.message}). ${REMEDIATION}`));
36
+ return;
37
+ }
38
+ let out = '';
39
+ let err = '';
40
+ child.stdout.on('data', (d) => (out += d));
41
+ child.stderr.on('data', (d) => (err += d));
42
+ child.on('error', (e) => {
43
+ // ENOENT etc. — python not found.
44
+ reject(new Error(`AAF offline preview needs Python + pyaaf2, but '${pythonCmd()}' could not start (${e.message}). ${REMEDIATION}`));
45
+ });
46
+ child.on('close', (code) => {
47
+ if (code === 0) {
48
+ try {
49
+ resolve(JSON.parse(out));
50
+ } catch (e) {
51
+ reject(new Error(`AAF probe returned unreadable output: ${e.message}`));
52
+ }
53
+ return;
54
+ }
55
+ if (code === 3 || /AAF_PROBE_NO_PYAAF2/.test(err)) {
56
+ reject(new Error(`AAF offline preview needs the pure-Python 'aaf2' package (pyaaf2), which is not installed. ${REMEDIATION}`));
57
+ return;
58
+ }
59
+ if (code === 4 || /AAF_PROBE_UNREADABLE/.test(err)) {
60
+ const detail = (err.match(/AAF_PROBE_UNREADABLE:\s*(.*)/) || [, err.trim()])[1];
61
+ reject(new Error(`AAF could not be read: ${detail || 'unreadable or not a valid AAF file'}.`));
62
+ return;
63
+ }
64
+ reject(new Error(`AAF probe failed (exit ${code}): ${err.trim() || 'unknown error'}. ${REMEDIATION}`));
65
+ });
66
+ });
67
+ }
68
+
69
+ /** Resolve the AAF path from the tool's `content`/`path` arg (AAF is binary — must be a path). */
70
+ export function resolveAafPath(contentOrPath) {
71
+ const p = typeof contentOrPath === 'string' ? contentOrPath.trim() : '';
72
+ if (!p) {
73
+ throw new Error('AAF is a binary format — pass the .aaf file PATH as `content` (or `path`), not inline bytes/text.');
74
+ }
75
+ if (!existsSync(p)) {
76
+ throw new Error(`AAF path does not exist: ${p}`);
77
+ }
78
+ return path.resolve(p);
79
+ }
80
+
81
+ /**
82
+ * Parse an AAF into a FLAT normalized-event list (mirrors parseEDL/parseOTIO/parseXMEML output),
83
+ * concatenating every top-level sequence. Use listAafSequences() when you need per-sequence split.
84
+ * @param {string} contentOrPath absolute .aaf path
85
+ * @returns {Promise<Array>} normalized events
86
+ */
87
+ export async function parseAAF(contentOrPath) {
88
+ const aafPath = resolveAafPath(contentOrPath);
89
+ const { sequences } = await runProbe(aafPath);
90
+ const events = [];
91
+ for (const seq of sequences || []) for (const ev of seq.events || []) events.push(ev);
92
+ return events;
93
+ }
94
+
95
+ /**
96
+ * Enumerate the sequences inside an AAF for the picker.
97
+ * @param {string} contentOrPath absolute .aaf path
98
+ * @returns {Promise<Array<{id:string,name:string,eventCount:number}>>}
99
+ */
100
+ export async function listAafSequences(contentOrPath) {
101
+ const aafPath = resolveAafPath(contentOrPath);
102
+ const { sequences } = await runProbe(aafPath);
103
+ return (sequences || []).map((s) => ({ id: String(s.id), name: String(s.name), eventCount: Number(s.eventCount || 0) }));
104
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Author an interchange (OTIO / EDL / DRT) FROM a normalized-event list — the write-side of the
3
+ * conform bridge. Its purpose: turn a parsed turnover (esp. .prproj, which Resolve can't import
4
+ * natively) into a format Resolve DOES import, WITHOUT round-tripping through Premiere.
5
+ *
6
+ * OTIO is the default target: it carries gaps, per-clip speed (LinearTimeWarp) and transitions, and
7
+ * round-trips through this repo's own parseOTIO. EDL is CMX3600 (cuts + M2 speed). DRT is authored
8
+ * via the vendored buildDRT (Resolve-native). Editorial timing/structure survives with high
9
+ * fidelity; per-clip effects/color do NOT (the Premiere→Resolve semantic gap, flagged not faked).
10
+ */
11
+ import { drt } from './libs.mjs';
12
+
13
+ const pad = (n, w = 2) => String(Math.max(0, Math.floor(n))).padStart(w, '0');
14
+
15
+ /** frames → CMX timecode at fps (non-drop). */
16
+ export function framesToTc(frames, fps) {
17
+ const f = Math.max(0, Math.round(Number(frames) || 0));
18
+ const r = Math.max(1, Math.round(fps || 24));
19
+ const ff = f % r;
20
+ const s = Math.floor(f / r);
21
+ return `${pad(Math.floor(s / 3600))}:${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}:${pad(ff)}`;
22
+ }
23
+
24
+ const byTrack = (events) => {
25
+ const groups = { V: [], A: [] };
26
+ for (const e of events) groups[e.track === 'A' ? 'A' : 'V'].push(e);
27
+ for (const k of Object.keys(groups)) groups[k].sort((a, b) => (a.recIn ?? 0) - (b.recIn ?? 0));
28
+ return groups;
29
+ };
30
+
31
+ /**
32
+ * Build an OTIO timeline doc (plain object) from normalized events. Inserts gaps so record
33
+ * positions are exact; emits LinearTimeWarp for speed/reverse and Transition items where present.
34
+ */
35
+ export function eventsToOTIO(events, opts = {}) {
36
+ const fps = opts.fps || events.find((e) => e.fps)?.fps || 24;
37
+ const groups = byTrack(events);
38
+ const tracks = [];
39
+ for (const [kind, list] of [
40
+ ['Video', groups.V],
41
+ ['Audio', groups.A],
42
+ ]) {
43
+ if (!list.length) continue;
44
+ const children = [];
45
+ let rec = 0;
46
+ for (const e of list) {
47
+ const recIn = e.recIn ?? rec;
48
+ if (recIn > rec) {
49
+ children.push({
50
+ OTIO_SCHEMA: 'Gap.1',
51
+ source_range: { OTIO_SCHEMA: 'TimeRange.1', duration: { OTIO_SCHEMA: 'RationalTime.1', value: recIn - rec, rate: fps } },
52
+ });
53
+ rec = recIn;
54
+ }
55
+ const recDur = (e.recOut ?? recIn) - recIn;
56
+ const clip = {
57
+ OTIO_SCHEMA: 'Clip.1',
58
+ name: e.source || 'UNKNOWN',
59
+ source_range: {
60
+ OTIO_SCHEMA: 'TimeRange.1',
61
+ start_time: { OTIO_SCHEMA: 'RationalTime.1', value: e.srcIn ?? 0, rate: e.fps || fps },
62
+ duration: { OTIO_SCHEMA: 'RationalTime.1', value: recDur, rate: e.fps || fps },
63
+ },
64
+ media_reference: { OTIO_SCHEMA: 'ExternalReference.1', target_url: e.source || '' },
65
+ effects: [],
66
+ markers: [],
67
+ };
68
+ if ((e.speed ?? 100) !== 100 || e.reverse) {
69
+ clip.effects.push({ OTIO_SCHEMA: 'LinearTimeWarp.1', name: 'Speed', time_scalar: (e.reverse ? -1 : 1) * ((e.speed ?? 100) / 100) });
70
+ }
71
+ if (e.transition) {
72
+ children.push({
73
+ OTIO_SCHEMA: 'Transition.1',
74
+ transition_type: 'SMPTE_Dissolve',
75
+ in_offset: { OTIO_SCHEMA: 'RationalTime.1', value: Math.ceil((e.transition.duration || 0) / 2), rate: fps },
76
+ out_offset: { OTIO_SCHEMA: 'RationalTime.1', value: Math.floor((e.transition.duration || 0) / 2), rate: fps },
77
+ });
78
+ }
79
+ children.push(clip);
80
+ rec = recIn + recDur;
81
+ }
82
+ tracks.push({ OTIO_SCHEMA: 'Track.1', name: `${kind[0]}1`, kind, children });
83
+ }
84
+ return { OTIO_SCHEMA: 'Timeline.1', name: opts.name || 'Conformed', tracks: { OTIO_SCHEMA: 'Stack.1', name: 'tracks', children: tracks } };
85
+ }
86
+
87
+ /** Build a CMX3600 EDL string (cuts + M2 speed). Video events only, per EDL convention. */
88
+ export function eventsToEDL(events, opts = {}) {
89
+ const fps = opts.fps || events.find((e) => e.fps)?.fps || 24;
90
+ const vids = events.filter((e) => e.track !== 'A').sort((a, b) => (a.recIn ?? 0) - (b.recIn ?? 0));
91
+ const lines = [`TITLE: ${opts.name || 'CONFORMED'}`, 'FCM: NON-DROP FRAME'];
92
+ vids.forEach((e, i) => {
93
+ const num = pad(i + 1, 3);
94
+ const reel =
95
+ String(e.source || 'AX')
96
+ .replace(/\.[^.]+$/, '')
97
+ .replace(/[^A-Za-z0-9]/g, '')
98
+ .slice(0, 8)
99
+ .toUpperCase() || 'AX';
100
+ lines.push(
101
+ `${num} ${reel} V C ${framesToTc(e.srcIn, fps)} ${framesToTc(e.srcOut, fps)} ${framesToTc(e.recIn, fps)} ${framesToTc(e.recOut, fps)}`,
102
+ );
103
+ if ((e.speed ?? 100) !== 100 || e.reverse) {
104
+ const play = (e.reverse ? -1 : 1) * (fps * ((e.speed ?? 100) / 100));
105
+ lines.push(`M2 ${reel} ${play.toFixed(1)} ${framesToTc(e.srcIn, fps)}`);
106
+ }
107
+ });
108
+ return lines.join('\n') + '\n';
109
+ }
110
+
111
+ /** Build a buildDRT spec (Resolve-native .drt) from normalized events. */
112
+ export function eventsToDrtSpec(events, opts = {}) {
113
+ const fps = opts.fps || events.find((e) => e.fps)?.fps || 24;
114
+ const groups = byTrack(events);
115
+ const mkTrack = (list) => ({
116
+ clips: list.map((e) => ({ start: e.recIn ?? 0, duration: (e.recOut ?? 0) - (e.recIn ?? 0), in: e.srcIn ?? 0, mediaFilePath: e.source || '' })),
117
+ });
118
+ return {
119
+ timelines: [
120
+ {
121
+ name: opts.name || 'Conformed',
122
+ frameRate: fps,
123
+ startTimecode: opts.startTimecode || '01:00:00:00',
124
+ resolution: opts.resolution || '1920x1080',
125
+ videoTracks: groups.V.length ? [mkTrack(groups.V)] : [],
126
+ audioTracks: groups.A.length ? [mkTrack(groups.A)] : [],
127
+ },
128
+ ],
129
+ metadata: { source: 'author-interchange', ...(opts.metadata || {}) },
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Author `events` into `target` interchange. Returns { target, content?, spec?, bytes? }.
135
+ * For 'drt', when outputPath is given the .drt bytes are written; otherwise the spec is returned.
136
+ */
137
+ export async function authorInterchange(events, target, opts = {}) {
138
+ const t = String(target || 'otio').toLowerCase();
139
+ if (t === 'otio') {
140
+ const doc = eventsToOTIO(events, opts);
141
+ return { target: 'otio', content: JSON.stringify(doc, null, 2), doc };
142
+ }
143
+ if (t === 'edl') {
144
+ return { target: 'edl', content: eventsToEDL(events, opts) };
145
+ }
146
+ if (t === 'drt') {
147
+ const spec = eventsToDrtSpec(events, opts);
148
+ const buf = await drt().buildDRT(spec);
149
+ return { target: 'drt', spec, buffer: buf, bytes: buf.length };
150
+ }
151
+ throw new Error(`authorInterchange: unknown target '${target}' (otio|edl|drt)`);
152
+ }
@@ -10,9 +10,11 @@
10
10
  * timing analogue of a faked grade.
11
11
  *
12
12
  * Interchange breadth AT INGEST: EDL (CMX3600) + OTIO (JSON) parse natively here; XMEML via a
13
- * light clipitem parse. AAF is binary — honest refuse (convert to OTIO/EDL upstream).
13
+ * light clipitem parse. AAF is binary — parsed offline via aaf.mjs pyaaf2 (async, out-of-band);
14
+ * parseInterchange() itself stays PURE and points AAF callers at that async path. Premiere .prproj
15
+ * is a closed binary project — honest refuse with an actionable convert-upstream message.
14
16
  *
15
- * PURE + deterministic. No Resolve, no LLM.
17
+ * PURE + deterministic (edl/otio/xmeml). No Resolve, no LLM.
16
18
  */
17
19
  import { createRequire } from 'node:module';
18
20
 
@@ -193,7 +195,11 @@ export function parseXMEMLEvents(xml, opts = {}) {
193
195
  return events;
194
196
  }
195
197
 
196
- /** Dispatch by format. AAF is an honest refuse (binary — convert to OTIO/EDL upstream). */
198
+ /**
199
+ * Dispatch by format (SYNC, pure over TEXT). Binary formats are handled out-of-band by their own
200
+ * path-based readers — AAF via aaf.mjs `parseAAF` (async, pyaaf2), .prproj via prproj.mjs
201
+ * `parsePrproj` (gunzip+XML). This throws to route callers there rather than faking a parse.
202
+ */
197
203
  export function parseInterchange(format, content, opts = {}) {
198
204
  switch (String(format).toLowerCase()) {
199
205
  case 'edl':
@@ -205,9 +211,15 @@ export function parseInterchange(format, content, opts = {}) {
205
211
  case 'fcp7':
206
212
  return parseXMEMLEvents(content, opts);
207
213
  case 'aaf':
208
- throw new Error('parse_interchange: AAF is binary — not parseable offline in pure JS. Convert to OTIO or EDL upstream (e.g. via OpenTimelineIO).');
214
+ throw new Error(
215
+ 'parse_interchange: AAF is binary — parse it via the async AAF path (aaf.mjs `parseAAF`, backed by pyaaf2), not the sync parseInterchange().',
216
+ );
217
+ case 'prproj':
218
+ throw new Error(
219
+ 'parse_interchange: .prproj is gzip-compressed XML — parse it via the path-based reader (prproj.mjs `parsePrproj`), not the sync parseInterchange().',
220
+ );
209
221
  default:
210
- throw new Error(`parse_interchange: unknown format '${format}' (edl|otio|xml|aaf)`);
222
+ throw new Error(`parse_interchange: unknown format '${format}' (edl|otio|xml|xmeml|fcp7|aaf|prproj)`);
211
223
  }
212
224
  }
213
225