davinci-resolve-mcp 2.58.0 → 2.60.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,98 @@
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.60.0
6
+
7
+ A community bug-fix bundle — three external contributions plus the three issues
8
+ they filed, integrated with the contributors credited as co-authors.
9
+
10
+ ### Fixed
11
+
12
+ - **Frame accuracy (`edit_engine`)** — `MediaPool.AppendToTimeline` clipInfo
13
+ `endFrame` is an *exclusive* bound (duration = `endFrame - startFrame`), but
14
+ three plan builders wrote an inclusive end (`round(t*fps) - 1`) and
15
+ `execute_selects` advanced its record cursor by `end - start + 1`. Result:
16
+ `plan_tighten` kept ranges were one frame short per segment (~4.3s across 130
17
+ segments), `plan_selects`/`plan_swap` source ranges were one frame short, and
18
+ `execute_selects` left a 1-frame gap between selects. Now half-open everywhere.
19
+ (#82, thanks @chenyuxiaojin)
20
+ - **Timeline rename no longer archives (`destructive_hook`)** — `timeline.set_name`
21
+ was version-on-mutate, so renames spawned redundant `_archived` timelines
22
+ (and renaming an archive archived the archive). A rename is content-preserving,
23
+ so it's out of the destructive registry. (#83, thanks @chenyuxiaojin)
24
+ - **Windows startup (`server`)** — initialize the Resolve scripting env
25
+ (PYTHONHOME, PATH, `os.add_dll_directory`) before importing the fusionscript
26
+ bridge, avoiding a native access violation that crashed network transports
27
+ before bind. No-op off Windows. (#78, thanks @POLEPALLIANVESH)
28
+ - Fixed a stale offline test that patched a refactored-away seam
29
+ (`project_manager` delete routing), restoring a fully green baseline.
30
+
31
+ ### Added
32
+
33
+ - **`execute_tighten(..., include_details?)`** — the readback `structural_diff`
34
+ is compact by default (counts + a small head/tail sample) instead of embedding
35
+ every before/after item id (226 KB for a 130-segment tighten). The full
36
+ per-item diff is persisted in the plan record (`get_plan` →
37
+ `execution_summary.structural_diff`) and returned inline with
38
+ `include_details=true`. (#84, thanks @chenyuxiaojin)
39
+ - **`plan_tighten` skip dedup** — identical `(item, reason)` skip rows collapse
40
+ into one entry with a `count` (an unanalyzed layer no longer repeats the same
41
+ row once per segment). (#81, thanks @chenyuxiaojin)
42
+
43
+ ### Documentation
44
+
45
+ - Recorded the `AppendToTimeline` `endFrame` exclusive-bound semantics in the
46
+ `api_truth` ledger (internal quirk entry). (#80, thanks @chenyuxiaojin)
47
+
48
+ ### Validation
49
+
50
+ - Full offline suite green (1343 tests). Static/drift guards pass.
51
+ - Live Resolve validation of the #82 frame-accuracy fix (selects butt-join +
52
+ tighten frame-exact keep ranges) on a disposable project.
53
+
54
+ ## What's New in v2.59.0
55
+
56
+ First-class conform ingest for **AAF** and **DRP**, an offline **Premiere `.prproj`** reader with
57
+ a conform bridge, and a unified sequence enumerator — so an editorial turnover in any of these
58
+ formats can be previewed, picked, and brought into Resolve. Everything keeps the honest-refuse
59
+ philosophy: no format is ever faked; an unreadable file yields a clear, actionable error.
60
+
61
+ ### Added
62
+
63
+ - **AAF** — offline preview via the advanced server's `editorial.parse_interchange` (format `aaf`)
64
+ and `list_sequences`, backed by the pure-Python `aaf2` (pyaaf2) reader (shelled out from Node;
65
+ honest-refuses with an install/convert hint when unavailable). Live AAF import now works through
66
+ `timeline.import_timeline_checked` — the XML sanitize pass is skipped for the binary format and,
67
+ when `relink_search_roots` is passed, a post-import media-pool relink runs (fuzzy-relink parity
68
+ via `RelinkClips`), reported in a `relink` block.
69
+ - **DRP** — `drt.list_sequences` enumerates the timelines inside a `.drp`/`.drt`
70
+ (`[{id, name, eventCount}]`) to drive a picker; `timeline.import_from_drp` extracts the chosen
71
+ timelines (offline zip surgery → temp `.drt`) and imports each into a running Resolve.
72
+ - **Premiere `.prproj`** — a from-scratch offline reader (gunzip + object-reference-graph walk, no
73
+ new dependencies) exposed through `parse_interchange` / `list_sequences`. Derives cuts, source
74
+ in/out, timeline positions, speed/retime, reverse, transitions, markers, and media paths. Effects
75
+ and Lumetri color are not translated (the Premiere→Resolve semantic gap, flagged not faked).
76
+ - **Conform bridge** — `editorial.convert_to_interchange` authors OTIO / EDL / DRT that Resolve
77
+ imports, from normalized events or a parsed source. This lets a `.prproj` be conformed into
78
+ Resolve with no Premiere in the loop.
79
+ - **Unified enumeration** — `editorial.list_sequences(path)` is one picker entry point across
80
+ xml/edl/otio/drt/drp/aaf/prproj.
81
+
82
+ ### Dependencies
83
+
84
+ - New optional-but-default `requirements.txt` pins `pyaaf2` (pure-Python, MIT, ~1 MB) for the
85
+ offline AAF reader; the installer points the advanced server's `AAF_PROBE_PYTHON` at the project
86
+ venv so it works out of the box. Without it, AAF preview honest-refuses; nothing else needs it.
87
+
88
+ ### Validation
89
+
90
+ - Static checks, drift guards, and focused unit tests pass (advanced Node suite 455 pass / 9 skip;
91
+ new `test_import_from_drp.py` and `prproj-bridge.test.mjs` / `aaf-sequences.test.mjs`). Offline
92
+ AAF parse validated end-to-end against a real `aaf2`-authored AAF.
93
+ - **Not yet live-validated in Resolve**: AAF import, the post-import relink, `import_from_drp`, and
94
+ importing an authored OTIO/DRT from a real `.prproj`. These paths are offline-tested (fake Resolve)
95
+ and guarded; confirm against a live session with disposable projects before relying on them.
96
+
5
97
  ## What's New in v2.58.0
6
98
 
7
99
  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.60.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/docs/SKILL.md CHANGED
@@ -794,8 +794,10 @@ clip-count readback plus `brain_edits` rationale rows.
794
794
  media-id fallback), and `readback` carries per-track-type
795
795
  `track_counts` plus an `audio_accounting` block so swap symmetry is
796
796
  verifiable. `execute_tighten` readback gains `structural_diff` (source
797
- vs variant, via the same engine as `diff_timelines`); `execute_selects`
798
- readback gains a `usage_summary`.
797
+ vs variant, via the same engine as `diff_timelines`) — compact by
798
+ default (counts + a small sample; full per-item diff persisted in the
799
+ plan record via `get_plan`, or inline with `include_details=true`);
800
+ `execute_selects` readback gains a `usage_summary`.
799
801
  - `list_plans(limit?)` / `get_plan(plan_id)`.
800
802
 
801
803
  The engine needs the analysis substrate: analyzed clips in the DB (run
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.60.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.60.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