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 +43 -0
- package/README.md +4 -11
- package/install.py +12 -4
- package/package.json +1 -1
- package/resolve-advanced/README.md +6 -1
- package/resolve-advanced/server/aaf.mjs +104 -0
- package/resolve-advanced/server/author-interchange.mjs +152 -0
- package/resolve-advanced/server/editorial.mjs +17 -5
- package/resolve-advanced/server/prproj.mjs +262 -0
- package/resolve-advanced/server/sequences.mjs +86 -0
- package/resolve-advanced/server/tools/drt.mjs +9 -1
- package/resolve-advanced/server/tools/editorial.mjs +80 -7
- package/src/granular/common.py +1 -1
- package/src/server.py +286 -8
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
|
7
|
+
[-18%20tools-blueviolet.svg)](#server-modes)
|
|
7
8
|
[](docs/reference/api-coverage.md#test-results)
|
|
8
9
|
[](https://www.blackmagicdesign.com/products/davinciresolve)
|
|
9
10
|
[](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 | **
|
|
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.
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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 —
|
|
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
|
-
/**
|
|
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(
|
|
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
|
|